24bf4706e1
- Environment/Deployment XORM models + migration 010 - Full CRUD API: GET/POST/PATCH/DELETE /environments + /deployments - Deployment status update endpoint, publishes deployment.* NATS events - EnvironmentsPage with deploy cards, history accordion, deploy modal - Sidebar Environments nav item between Pipelines and Settings - Repo page deployment status badges (env name + SHA pill per environment) - Environment/Deployment types in types/api.ts + environments.ts query hooks
276 lines
15 KiB
TypeScript
276 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
|
|
import { useEnvironments } from '../api/queries/environments'
|
|
import { TreeBrowser } from '../components/repos/TreeBrowser'
|
|
import { RepoListSkeleton } from '../ui/Skeleton'
|
|
import { RepoAvatar } from '../ui/RepoAvatar'
|
|
import { useRecentRepos } from '../hooks/useRecentRepos'
|
|
|
|
export default function RepoPage() {
|
|
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const [showBranches, setShowBranches] = useState(false)
|
|
const [showClone, setShowClone] = useState(false)
|
|
const branchRef = useRef<HTMLDivElement>(null)
|
|
const cloneRef = useRef<HTMLDivElement>(null)
|
|
|
|
const path = searchParams.get('path') ?? ''
|
|
const ref = searchParams.get('ref') ?? ''
|
|
|
|
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
|
|
const { data: branches } = useRepoBranches(owner, repoName)
|
|
const { data: environments } = useEnvironments(owner, repoName)
|
|
const { track } = useRecentRepos()
|
|
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
|
|
|
|
// Close dropdowns on outside click
|
|
useEffect(() => {
|
|
function handle(e: MouseEvent) {
|
|
if (branchRef.current && !branchRef.current.contains(e.target as Node)) setShowBranches(false)
|
|
if (cloneRef.current && !cloneRef.current.contains(e.target as Node)) setShowClone(false)
|
|
}
|
|
document.addEventListener('mousedown', handle)
|
|
return () => document.removeEventListener('mousedown', handle)
|
|
}, [])
|
|
|
|
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div>
|
|
if (isError || !repo) return <div className="p-6 text-sm text-[var(--c-danger)]">Repository not found.</div>
|
|
|
|
const branch = ref || repo.defaultBranch
|
|
const cloneUrl = `${window.location.origin}/${owner}/${repoName}.git`
|
|
|
|
function switchBranch(b: string) {
|
|
setSearchParams({ ref: b, ...(path ? { path } : {}) })
|
|
setShowBranches(false)
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4">
|
|
|
|
{/* Header row */}
|
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
<div>
|
|
<div className="flex items-center gap-2 text-sm text-[var(--c-muted)] mb-1">
|
|
<Link to="/repos" className="hover:text-[var(--c-brand)]">Repositories</Link>
|
|
<span>/</span>
|
|
<RepoAvatar ownerName={owner} name={repo.name} avatarUrl={repo.avatarUrl} size={20} />
|
|
<span className="font-semibold text-[var(--c-text)]">{repo.name}</span>
|
|
{repo.isPrivate && (
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-[var(--c-border)] text-[var(--c-muted)]">
|
|
Private
|
|
</span>
|
|
)}
|
|
</div>
|
|
{repo.description && (
|
|
<p className="text-sm text-[var(--c-muted)]">{repo.description}</p>
|
|
)}
|
|
|
|
{/* Deployment status badges */}
|
|
{environments && environments.length > 0 && (
|
|
<div className="flex items-center gap-1.5 flex-wrap mt-2">
|
|
{environments.map(env => {
|
|
const status = env.latestDeployment?.status
|
|
const dot: Record<string, string> = {
|
|
success: 'bg-[var(--c-success)]',
|
|
in_progress: 'bg-[var(--c-brand)] animate-pulse',
|
|
failure: 'bg-[var(--c-danger)]',
|
|
pending: 'bg-[var(--c-subtle)]',
|
|
cancelled: 'bg-[var(--c-subtle)]',
|
|
}
|
|
return (
|
|
<Link
|
|
key={env.id}
|
|
to={`/repos/${owner}/${repoName}/environments`}
|
|
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full border border-[var(--c-border)] bg-[var(--c-surface-muted)] hover:border-[var(--c-brand-focus)] transition-colors"
|
|
>
|
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${status ? (dot[status] ?? 'bg-[var(--c-subtle)]') : 'bg-[var(--c-subtle)]'}`} />
|
|
<span className="text-[10px] font-medium text-[var(--c-muted)]">{env.name}</span>
|
|
{env.latestDeployment?.sha && (
|
|
<span className="text-[10px] font-mono text-[var(--c-subtle)]">
|
|
{env.latestDeployment.sha.slice(0, 7)}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Link
|
|
to={`/repos/${owner}/${repoName}/pulls`}
|
|
className="px-3 py-1.5 border border-[var(--c-border)] rounded text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] font-medium"
|
|
>
|
|
Pull requests
|
|
</Link>
|
|
|
|
{/* Clone dropdown */}
|
|
<div className="relative" ref={cloneRef}>
|
|
<button
|
|
onClick={() => setShowClone(s => !s)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white text-sm font-medium"
|
|
>
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
|
|
</svg>
|
|
Clone
|
|
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24" className="ml-0.5">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
</button>
|
|
{showClone && (
|
|
<div className="absolute right-0 top-full mt-1 w-80 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-lg shadow-xl z-50 p-4">
|
|
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTP</p>
|
|
<div className="flex items-center gap-2 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2">
|
|
<code className="text-xs text-[var(--c-text)] flex-1 truncate">{cloneUrl}</code>
|
|
<button
|
|
onClick={() => navigator.clipboard.writeText(cloneUrl)}
|
|
className="text-[10px] text-[var(--c-brand)] hover:underline shrink-0"
|
|
>
|
|
Copy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{repo.isEmpty ? (
|
|
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} />
|
|
) : (
|
|
<>
|
|
{/* Branch selector */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<div className="relative" ref={branchRef}>
|
|
<button
|
|
onClick={() => setShowBranches(s => !s)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 border border-[var(--c-border)] rounded text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] font-medium bg-[var(--c-surface)]"
|
|
>
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
|
</svg>
|
|
{branch}
|
|
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
</button>
|
|
{showBranches && (
|
|
<div className="absolute left-0 top-full mt-1 w-56 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-lg shadow-xl z-50 overflow-hidden">
|
|
<div className="px-3 py-2 border-b border-[var(--c-border)] bg-[var(--c-surface-muted)]">
|
|
<p className="text-xs font-semibold text-[var(--c-muted)]">Switch branch</p>
|
|
</div>
|
|
<ul>
|
|
{branches?.map(b => (
|
|
<li key={b.name}>
|
|
<button
|
|
onClick={() => switchBranch(b.name)}
|
|
className="w-full text-left px-3 py-2 text-sm hover:bg-[var(--c-surface-muted)] flex items-center gap-2"
|
|
>
|
|
{b.name === branch && (
|
|
<svg width="12" height="12" fill="none" stroke="var(--c-brand)" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
|
</svg>
|
|
)}
|
|
<span className={b.name === branch ? 'text-[var(--c-brand)] font-medium' : 'text-[var(--c-text)] ml-5'}>{b.name}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
{!branches?.length && (
|
|
<li className="px-3 py-2 text-xs text-[var(--c-muted)]">No branches found</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Nav links */}
|
|
<Link to={`/repos/${owner}/${repoName}/commits`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Commits</Link>
|
|
<Link to={`/repos/${owner}/${repoName}/branches`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Branches</Link>
|
|
<Link to={`/repos/${owner}/${repoName}/issues`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Issues</Link>
|
|
<Link to={`/repos/${owner}/${repoName}/settings`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 ml-auto">Settings</Link>
|
|
</div>
|
|
|
|
<TreeBrowser owner={owner} repo={repoName} ref={branch} path={path} />
|
|
|
|
{/* README preview — only at repo root */}
|
|
{!path && <ReadmePreview owner={owner} repo={repoName} ref={branch} />}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ReadmePreview({ owner, repo, ref }: { owner: string; repo: string; ref: string }) {
|
|
const { data: entries } = useRepoTree(owner, repo, ref, '')
|
|
const readmeEntry = entries?.find(e => e.name.toLowerCase() === 'readme.md')
|
|
const { data: blob } = useRepoBlob(owner, repo, ref, readmeEntry?.name ?? '')
|
|
|
|
if (!readmeEntry || !blob) return null
|
|
|
|
return (
|
|
<div className="border border-[var(--c-border)] rounded bg-[var(--c-surface)] overflow-hidden">
|
|
<div className="px-4 py-2.5 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center gap-2">
|
|
<svg width="14" height="14" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
|
</svg>
|
|
<span className="text-sm font-semibold text-[var(--c-text)]">{readmeEntry.name}</span>
|
|
</div>
|
|
<div className="px-6 py-5 prose prose-sm max-w-none text-[var(--c-text)]
|
|
prose-headings:text-[var(--c-text)] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[var(--c-border)] prose-headings:pb-1
|
|
prose-a:text-[var(--c-brand)] prose-code:bg-[var(--c-surface-muted)] prose-code:px-1 prose-code:rounded prose-code:text-sm
|
|
prose-pre:bg-[var(--c-surface-muted)] prose-pre:border prose-pre:border-[var(--c-border)] prose-pre:rounded">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{blob.content}</ReactMarkdown>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function GettingStarted({ repoName, branch, cloneUrl }: {
|
|
repoName: string; branch: string; cloneUrl: string
|
|
}) {
|
|
return (
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
|
<div className="px-5 py-4 bg-[var(--c-surface-raised)] border-b border-[var(--c-border)]">
|
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Getting started</h2>
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">Push your first commit to get started.</p>
|
|
</div>
|
|
<div className="px-5 py-5 space-y-6 text-sm">
|
|
<div>
|
|
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTP</p>
|
|
<CopyBlock value={cloneUrl} />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">…or push an existing repository</p>
|
|
<CopyBlock value={`git remote add origin ${cloneUrl}\ngit branch -M ${branch}\ngit push -u origin ${branch}`} multiline />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">…or create a new repository on the command line</p>
|
|
<CopyBlock value={`echo "# ${repoName}" >> README.md\ngit init\ngit add README.md\ngit commit -m "first commit"\ngit branch -M ${branch}\ngit remote add origin ${cloneUrl}\ngit push -u origin ${branch}`} multiline />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CopyBlock({ value, multiline }: { value: string; multiline?: boolean }) {
|
|
const copy = () => navigator.clipboard.writeText(value).catch(() => {})
|
|
return (
|
|
<div className="relative group">
|
|
<pre className={`font-mono text-xs bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-4 py-3 overflow-x-auto text-[var(--c-text)] ${multiline ? 'whitespace-pre' : 'whitespace-nowrap'}`}>
|
|
{value}
|
|
</pre>
|
|
<button
|
|
onClick={copy}
|
|
className="absolute top-2 right-2 px-2 py-1 rounded text-[10px] font-medium bg-[var(--c-surface)] border border-[var(--c-border)] text-[var(--c-muted)] hover:text-[var(--c-text)] opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
Copy
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|