readme file is now rendering in repo. can now view files and edit them. can switch between branches with dropdown menu

This commit is contained in:
2026-05-07 11:28:06 +02:00
parent 779a1fdb82
commit dad82a79de
41 changed files with 2463 additions and 141 deletions
+2
View File
@@ -22,6 +22,7 @@ const RegisterPage = lazy(() => import('./pages/RegisterPage'))
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const ReposPage = lazy(() => import('./pages/ReposPage'))
const RepoPage = lazy(() => import('./pages/RepoPage'))
const BlobPage = lazy(() => import('./pages/BlobPage'))
const RepoSettingsPage = lazy(() => import('./pages/RepoSettingsPage'))
const RepoIssuesPage = lazy(() => import('./pages/RepoIssuesPage'))
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
@@ -68,6 +69,7 @@ export default function App() {
<Route path="repos" element={<S><ReposPage /></S>} />
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
<Route path="repos/:owner/:repo/blob" element={<S><BlobPage /></S>} />
<Route path="repos/:owner/:repo/commits" element={<S><CommitsPage /></S>} />
<Route path="repos/:owner/:repo/branches" element={<S><BranchesPage /></S>} />
<Route path="repos/:owner/:repo/settings" element={<S><RepoSettingsPage /></S>} />
+51
View File
@@ -33,10 +33,23 @@ const treeEntrySchema = z.object({
type: z.enum(['blob', 'tree']),
hash: z.string(),
name: z.string(),
size: z.number().default(0),
commitHash: z.string().default(''),
commitMsg: z.string().default(''),
commitDate: z.string().default(''),
})
const treeSchema = z.array(treeEntrySchema)
const blobSchema = z.object({
content: z.string(),
path: z.string(),
ref: z.string(),
})
const branchSchema = z.object({ name: z.string() })
const branchesSchema = z.array(branchSchema)
export function useRepos() {
return useQuery({
queryKey: ['repos'],
@@ -98,6 +111,44 @@ export function useDeleteRepo(owner: string, name: string) {
})
}
export function useRepoBranches(owner: string, name: string) {
return useQuery({
queryKey: ['repos', owner, name, 'branches'],
queryFn: () =>
api.get<{ name: string }[]>(`/api/v1/repos/${owner}/${name}/branches`, branchesSchema),
enabled: Boolean(owner && name),
})
}
export function useRepoBlob(owner: string, name: string, ref: string, path: string) {
return useQuery({
queryKey: ['repos', owner, name, 'blob', ref, path],
queryFn: () =>
api.get<{ content: string; path: string; ref: string }>(
`/api/v1/repos/${owner}/${name}/blob?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(path)}`,
blobSchema,
),
enabled: Boolean(owner && name && ref && path),
})
}
export function useUpdateBlob(owner: string, name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { path: string; content: string; message: string; branch: string }) =>
api.put<{ status: string }>(
`/api/v1/repos/${owner}/${name}/blob`,
z.object({ status: z.string() }),
data,
),
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ queryKey: ['repos', owner, name, 'blob', vars.branch, vars.path] })
queryClient.invalidateQueries({ queryKey: ['repos', owner, name, 'tree'] })
queryClient.invalidateQueries({ queryKey: ['repos', owner, name, 'commits'] })
},
})
}
export function useCreateRepo() {
const queryClient = useQueryClient()
return useMutation({
+83 -72
View File
@@ -1,8 +1,6 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useRepoTree } from '../../api/queries/repos'
import { Skeleton } from '../../ui/Skeleton'
import { cn } from '../../lib/utils'
interface TreeBrowserProps {
owner: string
@@ -11,6 +9,28 @@ interface TreeBrowserProps {
path?: string
}
function relativeTime(iso: string): string {
if (!iso) return ''
const diff = Date.now() - new Date(iso).getTime()
const s = Math.floor(diff / 1000)
if (s < 60) return `${s}s ago`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
if (d < 30) return `${d}d ago`
const mo = Math.floor(d / 30)
if (mo < 12) return `${mo}mo ago`
return `${Math.floor(mo / 12)}y ago`
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
const { data: entries, isLoading, isError } = useRepoTree(owner, repo, ref, path)
@@ -24,10 +44,11 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
const dirs = entries.filter(e => e.type === 'tree').sort((a, b) => a.name.localeCompare(b.name))
const files = entries.filter(e => e.type === 'blob').sort((a, b) => a.name.localeCompare(b.name))
const sorted = [...dirs, ...files]
return (
<div className="border border-[#DFE1E6] rounded overflow-hidden">
{/* Breadcrumb */}
<div className="border border-[#DFE1E6] rounded overflow-hidden bg-white">
{/* Path breadcrumb inside tree */}
{path && (
<div className="flex items-center gap-1 px-3 py-2 bg-[#F4F5F7] border-b border-[#DFE1E6] text-xs text-[#5E6C84]">
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[#0052CC]">{repo}</Link>
@@ -37,7 +58,7 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
<span key={partial} className="flex items-center gap-1">
<span>/</span>
{i < arr.length - 1
? <Link to={`/repos/${owner}/${repo}?path=${partial}`} className="hover:text-[#0052CC]">{seg}</Link>
? <Link to={`/repos/${owner}/${repo}?path=${partial}&ref=${ref}`} className="hover:text-[#0052CC]">{seg}</Link>
: <span className="text-[#172B4D] font-medium">{seg}</span>
}
</span>
@@ -46,83 +67,73 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
</div>
)}
{/* Entries */}
<ul>
{[...dirs, ...files].map((entry, i) => {
const entryPath = path ? `${path}/${entry.name}` : entry.name
const isDir = entry.type === 'tree'
const href = isDir
? `/repos/${owner}/${repo}?path=${entryPath}`
: `/repos/${owner}/${repo}/blob?ref=${ref}&path=${entryPath}`
<table className="w-full text-sm border-collapse">
<colgroup>
<col className="w-auto" />
<col className="w-48 hidden sm:table-column" />
<col className="w-28" />
</colgroup>
<tbody>
{sorted.map(entry => {
const entryPath = path ? `${path}/${entry.name}` : entry.name
const isDir = entry.type === 'tree'
const href = isDir
? `/repos/${owner}/${repo}?path=${encodeURIComponent(entryPath)}&ref=${ref}`
: `/repos/${owner}/${repo}/blob?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(entryPath)}`
return (
<li
key={entry.hash}
className={cn(
'flex items-center gap-3 px-3 py-2 hover:bg-[#FAFBFC] text-sm border-b border-[#DFE1E6] last:border-b-0',
i === 0 && !path && 'border-t-0',
)}
>
{isDir ? (
<svg width="16" height="16" fill="#0052CC" viewBox="0 0 24 24" className="shrink-0">
<path d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
</svg>
) : (
<svg width="16" height="16" fill="none" stroke="#5E6C84" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<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>
)}
<Link
to={href}
className={cn(
'flex-1 truncate',
isDir ? 'text-[#0052CC] hover:underline' : 'text-[#172B4D] hover:text-[#0052CC]',
)}
>
{entry.name}
</Link>
</li>
)
})}
</ul>
return (
<tr key={entry.hash} className="border-b border-[#DFE1E6] last:border-b-0 hover:bg-[#FAFBFC]">
{/* Name */}
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{isDir ? (
<svg width="16" height="16" fill="#0052CC" viewBox="0 0 24 24" className="shrink-0">
<path d="M19.5 21h-15A2.25 2.25 0 0 1 2.25 18.75V6.75A2.25 2.25 0 0 1 4.5 4.5h4.086c.398 0 .779.158 1.06.44l1.415 1.414c.28.281.661.44 1.06.44H19.5A2.25 2.25 0 0 1 21.75 9v9.75A2.25 2.25 0 0 1 19.5 21Z" />
</svg>
) : (
<svg width="16" height="16" fill="none" stroke="#5E6C84" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<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>
)}
<Link
to={href}
className={isDir ? 'text-[#0052CC] hover:underline font-medium' : 'text-[#172B4D] hover:text-[#0052CC]'}
>
{entry.name}
</Link>
{!isDir && entry.size > 0 && (
<span className="text-[10px] text-[#5E6C84] hidden sm:inline">{formatSize(entry.size)}</span>
)}
</div>
</td>
{/* Commit message */}
<td className="px-3 py-2 text-xs text-[#5E6C84] truncate max-w-0 hidden sm:table-cell">
<span className="truncate block" title={entry.commitMsg}>{entry.commitMsg}</span>
</td>
{/* Date */}
<td className="px-3 py-2 text-xs text-[#5E6C84] whitespace-nowrap text-right">
{relativeTime(entry.commitDate)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
function TreeSkeleton() {
return (
<div className="border border-[#DFE1E6] rounded overflow-hidden">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2 border-b border-[#DFE1E6] last:border-b-0">
<div className="border border-[#DFE1E6] rounded overflow-hidden bg-white">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5 border-b border-[#DFE1E6] last:border-b-0">
<Skeleton className="w-4 h-4 shrink-0" />
<Skeleton className={`h-4 ${i % 2 === 0 ? 'w-32' : 'w-48'}`} />
<Skeleton className={`h-4 ${i % 3 === 0 ? 'w-20' : i % 3 === 1 ? 'w-36' : 'w-28'}`} />
<Skeleton className="h-3 w-40 ml-auto hidden sm:block" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
)
}
// Collapsible row used in inline tree navigation
export function TreeRow({
name,
type,
href,
}: {
name: string
type: 'blob' | 'tree'
href: string
}) {
const [open, setOpen] = useState(false)
return (
<div>
<Link to={href} className="flex items-center gap-2 py-1 hover:text-[#0052CC] text-sm">
{type === 'tree' && (
<button onClick={e => { e.preventDefault(); setOpen(o => !o) }} className="w-4 text-[#5E6C84]">
{open ? '▾' : '▸'}
</button>
)}
<span>{name}</span>
</Link>
</div>
)
}
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react'
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useRepo, useRepoBlob, useUpdateBlob } from '../api/queries/repos'
import { RepoListSkeleton } from '../ui/Skeleton'
export default function BlobPage() {
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [editing, setEditing] = useState(false)
const [editContent, setEditContent] = useState('')
const [commitMsg, setCommitMsg] = useState('')
const [preview, setPreview] = useState(false)
const ref = searchParams.get('ref') ?? ''
const filePath = searchParams.get('path') ?? ''
const fileName = filePath.split('/').pop() ?? filePath
const { data: repo } = useRepo(owner, repoName)
const { data: blob, isLoading, isError } = useRepoBlob(owner, repoName, ref, filePath)
const updateBlob = useUpdateBlob(owner, repoName)
const branch = ref || repo?.defaultBranch || 'main'
const isMarkdown = fileName.toLowerCase().endsWith('.md')
function startEdit() {
setEditContent(blob?.content ?? '')
setCommitMsg(`Update ${fileName}`)
setEditing(true)
setPreview(false)
}
function cancelEdit() {
setEditing(false)
setPreview(false)
}
async function handleCommit() {
if (!commitMsg.trim() || !filePath) return
await updateBlob.mutateAsync({
path: filePath,
content: editContent,
message: commitMsg.trim(),
branch,
})
setEditing(false)
navigate(`/repos/${owner}/${repoName}/blob?ref=${encodeURIComponent(branch)}&path=${encodeURIComponent(filePath)}`, { replace: true })
}
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div>
if (isError || !blob) return <div className="p-6 text-sm text-[#DE350B]">File not found.</div>
const lines = blob.content.split('\n')
const pathParts = filePath.split('/')
return (
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm flex-wrap">
<Link to="/repos" className="text-[#0052CC] hover:underline">Repositories</Link>
<span className="text-[#5E6C84]">/</span>
<Link to={`/repos/${owner}/${repoName}`} className="text-[#0052CC] hover:underline">{repoName}</Link>
{pathParts.map((seg, i) => {
const partial = pathParts.slice(0, i + 1).join('/')
const isLast = i === pathParts.length - 1
return (
<span key={partial} className="flex items-center gap-1">
<span className="text-[#5E6C84]">/</span>
{isLast
? <span className="font-semibold text-[#172B4D]">{seg}</span>
: <Link to={`/repos/${owner}/${repoName}?path=${encodeURIComponent(partial)}&ref=${encodeURIComponent(branch)}`} className="text-[#0052CC] hover:underline">{seg}</Link>
}
</span>
)
})}
</div>
{/* File card */}
<div className="border border-[#DFE1E6] rounded bg-white overflow-hidden">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[#DFE1E6] bg-[#FAFBFC] gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm">
{/* Branch pill */}
<span className="flex items-center gap-1 px-2 py-0.5 border border-[#DFE1E6] rounded text-xs text-[#5E6C84] bg-white">
<svg width="12" height="12" 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}
</span>
<span className="text-[#5E6C84]">{repoName}</span>
<span className="text-[#5E6C84]">/</span>
<span className="font-medium text-[#172B4D]">{fileName}</span>
<button
onClick={() => navigator.clipboard.writeText(filePath)}
className="text-[#5E6C84] hover:text-[#172B4D]"
title="Copy path"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
</button>
</div>
{!editing && (
<div className="flex items-center gap-1">
{isMarkdown && (
<button
onClick={() => setPreview(p => !p)}
className={`px-3 py-1.5 text-xs font-medium rounded border ${preview ? 'border-[#0052CC] text-[#0052CC] bg-[#DEEBFF]' : 'border-[#DFE1E6] text-[#5E6C84] hover:bg-[#F4F5F7]'}`}
>
{preview ? 'Source' : 'Preview'}
</button>
)}
<button
onClick={startEdit}
className="px-3 py-1.5 text-xs font-medium border border-[#DFE1E6] rounded text-[#172B4D] hover:bg-[#F4F5F7] flex items-center gap-1.5"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
</svg>
Edit
</button>
<button
onClick={() => navigator.clipboard.writeText(blob.content)}
className="px-3 py-1.5 text-xs font-medium border border-[#DFE1E6] rounded text-[#5E6C84] hover:bg-[#F4F5F7]"
>
Copy
</button>
</div>
)}
</div>
{/* Content */}
{editing ? (
<div className="flex flex-col">
<textarea
value={editContent}
onChange={e => setEditContent(e.target.value)}
className="w-full font-mono text-xs text-[#172B4D] bg-white p-4 resize-none focus:outline-none border-b border-[#DFE1E6]"
style={{ minHeight: Math.max(300, lines.length * 20) }}
spellCheck={false}
/>
<div className="p-4 bg-[#FAFBFC] border-t border-[#DFE1E6] space-y-3">
<div>
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Commit message</label>
<input
value={commitMsg}
onChange={e => setCommitMsg(e.target.value)}
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]"
placeholder="Describe your changes…"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCommit}
disabled={updateBlob.isPending || !commitMsg.trim()}
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] disabled:opacity-50"
>
{updateBlob.isPending ? 'Committing…' : 'Commit changes'}
</button>
<button onClick={cancelEdit} className="px-4 py-2 rounded border border-[#DFE1E6] text-sm text-[#172B4D] hover:bg-[#F4F5F7]">
Cancel
</button>
{updateBlob.isError && (
<span className="text-xs text-[#DE350B]">{(updateBlob.error as Error)?.message}</span>
)}
</div>
</div>
</div>
) : isMarkdown && preview ? (
<div className="px-6 py-5 prose prose-sm max-w-none text-[#172B4D]
prose-headings:text-[#172B4D] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[#DFE1E6] prose-headings:pb-1
prose-a:text-[#0052CC] prose-code:bg-[#F4F5F7] prose-code:px-1 prose-code:rounded
prose-pre:bg-[#F4F5F7] prose-pre:border prose-pre:border-[#DFE1E6] prose-pre:rounded">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{blob.content}</ReactMarkdown>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse font-mono text-xs">
<tbody>
{lines.map((line, i) => (
<tr key={i} className="hover:bg-[#FFFBDD]">
<td className="select-none text-right text-[#5E6C84] px-4 py-0.5 w-12 border-r border-[#DFE1E6] bg-[#FAFBFC] sticky left-0">
{i + 1}
</td>
<td className="px-4 py-0.5 text-[#172B4D] whitespace-pre">{line || ' '}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
+167 -58
View File
@@ -1,68 +1,200 @@
import { useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useParams, useSearchParams, Link } from 'react-router-dom'
import { useRepo } from '../api/queries/repos'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
import { TreeBrowser } from '../components/repos/TreeBrowser'
import { RepoListSkeleton } from '../ui/Skeleton'
import { useRecentRepos } from '../hooks/useRecentRepos'
export default function RepoPage() {
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
const [searchParams] = useSearchParams()
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 { 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-[#DE350B]">Repository not found.</div>
const branch = ref || repo.defaultBranch
const cloneUrl = `http://localhost:8080/${owner}/${repoName}.git`
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-6">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm">
<Link to="/repos" className="text-[#0052CC] hover:underline">Repositories</Link>
<span className="text-[#5E6C84]">/</span>
<span className="font-semibold text-[#172B4D]">{repo.name}</span>
{repo.isPrivate && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-[#DFE1E6] text-[#5E6C84] ml-1">
Private
</span>
)}
</div>
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4">
{repo.description && (
<p className="text-sm text-[#5E6C84]">{repo.description}</p>
)}
{/* Header row */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-2 text-sm text-[#5E6C84] mb-1">
<Link to="/repos" className="hover:text-[#0052CC]">Repositories</Link>
<span>/</span>
<span className="font-semibold text-[#172B4D]">{repo.name}</span>
{repo.isPrivate && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-[#DFE1E6] text-[#5E6C84]">
Private
</span>
)}
</div>
{repo.description && (
<p className="text-sm text-[#5E6C84]">{repo.description}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Link
to={`/repos/${owner}/${repoName}/pulls`}
className="px-3 py-1.5 border border-[#DFE1E6] rounded text-sm text-[#172B4D] hover:bg-[#F4F5F7] 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-[#0052CC] hover:bg-[#0065FF] 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-white border border-[#DFE1E6] rounded-lg shadow-xl z-50 p-4">
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">Clone over HTTP</p>
<div className="flex items-center gap-2 bg-[#F4F5F7] border border-[#DFE1E6] rounded px-3 py-2">
<code className="text-xs text-[#172B4D] flex-1 truncate">{cloneUrl}</code>
<button
onClick={() => navigator.clipboard.writeText(cloneUrl)}
className="text-[10px] text-[#0052CC] hover:underline shrink-0"
>
Copy
</button>
</div>
</div>
)}
</div>
</div>
</div>
{repo.isEmpty ? (
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} />
) : (
<>
{/* Branch pill + repo nav tabs */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border border-[#DFE1E6] rounded text-sm text-[#172B4D]">
<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}
{/* 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-[#DFE1E6] rounded text-sm text-[#172B4D] hover:bg-[#F4F5F7] font-medium bg-white"
>
<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-white border border-[#DFE1E6] rounded-lg shadow-xl z-50 overflow-hidden">
<div className="px-3 py-2 border-b border-[#DFE1E6] bg-[#F4F5F7]">
<p className="text-xs font-semibold text-[#5E6C84]">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-[#F4F5F7] flex items-center gap-2"
>
{b.name === branch && (
<svg width="12" height="12" fill="none" stroke="#0052CC" 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-[#0052CC] font-medium' : 'text-[#172B4D] ml-5'}>{b.name}</span>
</button>
</li>
))}
{!branches?.length && (
<li className="px-3 py-2 text-xs text-[#5E6C84]">No branches found</li>
)}
</ul>
</div>
)}
</div>
<Link to={`/repos/${owner}/${repoName}/issues`} className="text-sm text-[#0052CC] hover:underline">Issues</Link>
<Link to={`/repos/${owner}/${repoName}/pulls`} className="text-sm text-[#0052CC] hover:underline">Pull requests</Link>
<Link to={`/repos/${owner}/${repoName}/settings`} className="text-sm text-[#5E6C84] hover:text-[#172B4D] hover:underline ml-auto">Settings</Link>
{/* Nav links */}
<Link to={`/repos/${owner}/${repoName}/commits`} className="text-sm text-[#5E6C84] hover:text-[#172B4D] px-2 py-1">Commits</Link>
<Link to={`/repos/${owner}/${repoName}/branches`} className="text-sm text-[#5E6C84] hover:text-[#172B4D] px-2 py-1">Branches</Link>
<Link to={`/repos/${owner}/${repoName}/issues`} className="text-sm text-[#5E6C84] hover:text-[#172B4D] px-2 py-1">Issues</Link>
<Link to={`/repos/${owner}/${repoName}/settings`} className="text-sm text-[#5E6C84] hover:text-[#172B4D] 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-[#DFE1E6] rounded bg-white overflow-hidden">
<div className="px-4 py-2.5 border-b border-[#DFE1E6] bg-[#FAFBFC] flex items-center gap-2">
<svg width="14" height="14" fill="none" stroke="#5E6C84" 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-[#172B4D]">{readmeEntry.name}</span>
</div>
<div className="px-6 py-5 prose prose-sm max-w-none text-[#172B4D]
prose-headings:text-[#172B4D] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[#DFE1E6] prose-headings:pb-1
prose-a:text-[#0052CC] prose-code:bg-[#F4F5F7] prose-code:px-1 prose-code:rounded prose-code:text-sm
prose-pre:bg-[#F4F5F7] prose-pre:border prose-pre:border-[#DFE1E6] prose-pre:rounded">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{blob.content}</ReactMarkdown>
</div>
</div>
)
}
function GettingStarted({ repoName, branch, cloneUrl }: {
repoName: string; branch: string; cloneUrl: string
}) {
@@ -70,42 +202,20 @@ function GettingStarted({ repoName, branch, cloneUrl }: {
<div className="border border-[#DFE1E6] rounded-lg overflow-hidden">
<div className="px-5 py-4 bg-[#FAFBFC] border-b border-[#DFE1E6]">
<h2 className="text-sm font-semibold text-[#172B4D]">Getting started</h2>
<p className="text-xs text-[#5E6C84] mt-0.5">
This repository is empty. Push your first commit to get started.
</p>
<p className="text-xs text-[#5E6C84] mt-0.5">Push your first commit to get started.</p>
</div>
<div className="px-5 py-5 space-y-6 text-sm">
{/* Quick setup */}
<div>
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">
Quick setup clone over HTTP
</p>
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">Clone over HTTP</p>
<CopyBlock value={cloneUrl} />
</div>
{/* Push an existing repo */}
<div>
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">
or push an existing repository
</p>
<CopyBlock value={`git remote add origin ${cloneUrl}
git branch -M ${branch}
git push -u origin ${branch}`} multiline />
<p className="text-xs font-semibold text-[#5E6C84] 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>
{/* Create new */}
<div>
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">
or create a new repository on the command line
</p>
<CopyBlock value={`echo "# ${repoName}" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M ${branch}
git remote add origin ${cloneUrl}
git push -u origin ${branch}`} multiline />
<p className="text-xs font-semibold text-[#5E6C84] 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>
@@ -114,7 +224,6 @@ git push -u origin ${branch}`} multiline />
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-[#F4F5F7] border border-[#DFE1E6] rounded px-4 py-3 overflow-x-auto text-[#172B4D] ${multiline ? 'whitespace-pre' : 'whitespace-nowrap'}`}>
+4
View File
@@ -44,6 +44,10 @@ export interface TreeEntry {
type: 'blob' | 'tree'
hash: string
name: string
size: number
commitHash: string
commitMsg: string
commitDate: string
}
export interface Commit {