Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a81bda00e | |||
| ec9a286d33 |
@@ -41,6 +41,15 @@ NATS_URL=nats://localhost:4222
|
||||
# openssl ecparam -genkey -name prime256v1 -noout -out signing-key.pem
|
||||
# ARTIFACT_SIGNING_KEY=
|
||||
|
||||
# ─── SSH Server ────────────────────────────────────────────────────────────────
|
||||
# Hostname shown in SSH clone URLs. Auto-detected from INSTANCE_URL or request
|
||||
# Host header when empty.
|
||||
# SSH_HOST=ssh.example.com
|
||||
# SSH_PORT=2222
|
||||
# Path to PEM-encoded SSH host key. If empty, an ephemeral RSA-4096 key is
|
||||
# generated at startup (host key changes on restart — warns clients).
|
||||
# SSH_HOST_KEY_PATH=
|
||||
|
||||
# ─── OCI Registry (Phase 4) ───────────────────────────────────────────────────
|
||||
# Root directory for the OCI Distribution Spec blob and upload storage.
|
||||
OCI_ROOT=/var/lib/forgebucket/oci
|
||||
|
||||
Generated
+5156
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,25 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-cpp": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/lang-java": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.0",
|
||||
"@codemirror/lang-markdown": "^6.0.0",
|
||||
"@codemirror/lang-python": "^6.0.0",
|
||||
"@codemirror/lang-rust": "^6.0.0",
|
||||
"@codemirror/lang-xml": "^6.0.0",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/merge": "^6.12.1",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/theme-one-dark": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { api } from '../client'
|
||||
|
||||
const langStatSchema = z.object({
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
count: z.number(),
|
||||
pct: z.number(),
|
||||
})
|
||||
|
||||
const contributorSchema = z.object({
|
||||
name: z.string(),
|
||||
commits: z.number(),
|
||||
})
|
||||
|
||||
const insightsSchema = z.object({
|
||||
languages: z.array(langStatSchema),
|
||||
contributors: z.array(contributorSchema),
|
||||
totalCommits: z.number(),
|
||||
})
|
||||
|
||||
export type LangStat = z.infer<typeof langStatSchema>
|
||||
export type Contributor = z.infer<typeof contributorSchema>
|
||||
export type RepoInsights = z.infer<typeof insightsSchema>
|
||||
|
||||
export function useRepoInsights(owner: string, repo: string) {
|
||||
return useQuery<RepoInsights>({
|
||||
queryKey: ['repos', owner, repo, 'insights'],
|
||||
queryFn: () => api.get<RepoInsights>(`/api/v1/repos/${owner}/${repo}/insights`, insightsSchema),
|
||||
enabled: !!owner && !!repo,
|
||||
staleTime: 5 * 60 * 1000, // 5 min — git stats don't change on every page load
|
||||
})
|
||||
}
|
||||
@@ -194,6 +194,42 @@ export function useCreateRepo() {
|
||||
})
|
||||
}
|
||||
|
||||
const latestDeploymentSchema = z.object({
|
||||
envName: z.string(),
|
||||
status: z.string(),
|
||||
sha: z.string(),
|
||||
finishedAt: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
const pipelineRunSchema = z.object({
|
||||
id: z.number(),
|
||||
status: z.string(),
|
||||
triggerRef: z.string().optional(),
|
||||
startedAt: z.string().nullable().optional(),
|
||||
finishedAt: z.string().nullable().optional(),
|
||||
}).nullable()
|
||||
|
||||
const repoHealthSchema = z.object({
|
||||
ciPassRate7d: z.number(),
|
||||
totalRuns7d: z.number(),
|
||||
latestRun: pipelineRunSchema.optional(),
|
||||
latestDeployments: z.array(latestDeploymentSchema),
|
||||
openDriftCount: z.number(),
|
||||
openPRCount: z.number(),
|
||||
})
|
||||
|
||||
export type RepoHealth = z.infer<typeof repoHealthSchema>
|
||||
|
||||
export function useRepoHealth(owner: string, repo: string) {
|
||||
return useQuery<RepoHealth>({
|
||||
queryKey: ['repos', owner, repo, 'health'],
|
||||
queryFn: () =>
|
||||
api.get<RepoHealth>(`/api/v1/repos/${owner}/${repo}/health`, repoHealthSchema),
|
||||
enabled: Boolean(owner && repo),
|
||||
staleTime: 60 * 1000, // 1 min
|
||||
})
|
||||
}
|
||||
|
||||
export function useImportRepo() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRepoTree } from '../../api/queries/repos'
|
||||
import { getCSRFToken } from '../../api/client'
|
||||
import { Skeleton } from '../../ui/Skeleton'
|
||||
|
||||
interface TreeBrowserProps {
|
||||
@@ -32,23 +35,141 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: entries, isLoading, isError } = useRepoTree(owner, repo, ref, path)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
const zipInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadStatus, setUploadStatus] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
async function handleUpload(files: FileList | null, isZip = false) {
|
||||
if (!files || files.length === 0) return
|
||||
setUploading(true)
|
||||
setUploadStatus(`Uploading ${isZip ? 'archive' : `${files.length} file${files.length > 1 ? 's' : ''}`}…`)
|
||||
|
||||
try {
|
||||
const csrfToken = await getCSRFToken()
|
||||
const form = new FormData()
|
||||
form.append('branch', ref || 'main')
|
||||
form.append('message', isZip ? 'Upload archive' : `Upload ${files.length} file${files.length > 1 ? 's' : ''}`)
|
||||
|
||||
if (isZip) {
|
||||
form.append('zip', files[0])
|
||||
} else {
|
||||
for (const file of Array.from(files)) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
|
||||
const f = new File([file], relativePath, { type: file.type })
|
||||
form.append('file[]', f, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/v1/repos/${owner}/${repo}/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
body: form,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error || 'Upload failed')
|
||||
}
|
||||
|
||||
const result = await res.json()
|
||||
setUploadStatus(`Committed ${result.committed} file${result.committed !== 1 ? 's' : ''}`)
|
||||
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'tree'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'files'] })
|
||||
setTimeout(() => setUploadStatus(null), 3000)
|
||||
} catch (err) {
|
||||
setUploadStatus(`Error: ${(err as Error).message}`)
|
||||
setTimeout(() => setUploadStatus(null), 5000)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
if (folderInputRef.current) folderInputRef.current.value = ''
|
||||
if (zipInputRef.current) zipInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <TreeSkeleton />
|
||||
if (isError) return <p className="text-xs text-[var(--c-danger)] p-4">Failed to load file tree.</p>
|
||||
if (!entries?.length) return (
|
||||
<div className="border border-dashed border-[var(--c-border)] rounded p-6 text-center text-xs text-[var(--c-muted)]">
|
||||
No files yet — push your first commit to see them here.
|
||||
</div>
|
||||
)
|
||||
|
||||
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 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-[var(--c-border)] rounded overflow-hidden bg-[var(--c-surface)]">
|
||||
{/* Path breadcrumb inside tree */}
|
||||
|
||||
{/* Upload toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)] flex-wrap">
|
||||
<button
|
||||
onClick={() => navigate(`/repos/${owner}/${repo}/blob?ref=${encodeURIComponent(ref || 'main')}&new=true`)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)]"
|
||||
>
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New file
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
|
||||
>
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
Upload files
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
|
||||
>
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v8.25" />
|
||||
</svg>
|
||||
Upload folder
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => zipInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
|
||||
>
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z" />
|
||||
</svg>
|
||||
Upload ZIP
|
||||
</button>
|
||||
|
||||
{uploadStatus && (
|
||||
<span className={`text-xs ml-1 ${uploadStatus.startsWith('Error') ? 'text-[var(--c-danger)]' : 'text-[var(--c-success)]'}`}>
|
||||
{uploadStatus}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Hidden file inputs */}
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={e => handleUpload(e.target.files)} />
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
// @ts-expect-error webkitdirectory is not in React types
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
onChange={e => handleUpload(e.target.files)}
|
||||
/>
|
||||
<input ref={zipInputRef} type="file" accept=".zip" className="hidden" onChange={e => handleUpload(e.target.files, true)} />
|
||||
</div>
|
||||
|
||||
{/* Path breadcrumb */}
|
||||
{path && (
|
||||
<div className="flex items-center gap-1 px-3 py-2 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)] text-xs text-[var(--c-muted)]">
|
||||
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{repo}</Link>
|
||||
@@ -67,6 +188,11 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-[var(--c-muted)]">
|
||||
No files yet — push your first commit or upload files above.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-auto" />
|
||||
@@ -83,7 +209,6 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||
|
||||
return (
|
||||
<tr key={entry.hash} className="border-b border-[var(--c-border)] last:border-b-0 hover:bg-[var(--c-surface-raised)]">
|
||||
{/* Name */}
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDir ? (
|
||||
@@ -95,10 +220,7 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||
<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-[var(--c-brand)] hover:underline font-medium' : 'text-[var(--c-text)] hover:text-[var(--c-brand)]'}
|
||||
>
|
||||
<Link to={href} className={isDir ? 'text-[var(--c-brand)] hover:underline font-medium' : 'text-[var(--c-text)] hover:text-[var(--c-brand)]'}>
|
||||
{entry.name}
|
||||
</Link>
|
||||
{!isDir && entry.size > 0 && (
|
||||
@@ -106,11 +228,9 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{/* Commit message */}
|
||||
<td className="px-3 py-2 text-xs text-[var(--c-muted)] 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-[var(--c-muted)] whitespace-nowrap text-right">
|
||||
{relativeTime(entry.commitDate)}
|
||||
</td>
|
||||
@@ -119,6 +239,7 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
--c-warning: #FBBF24;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
@@ -4,6 +4,8 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { useRepo, useRepoBlob, useUpdateBlob } from '../api/queries/repos'
|
||||
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||
import { CodeEditor } from '../components/repos/CodeEditor'
|
||||
import { FileSideTree } from '../components/repos/FileSideTree'
|
||||
|
||||
export default function BlobPage() {
|
||||
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
|
||||
@@ -14,12 +16,17 @@ export default function BlobPage() {
|
||||
const [commitMsg, setCommitMsg] = useState('')
|
||||
const [preview, setPreview] = useState(false)
|
||||
|
||||
// New-file mode: ?new=true&path=<desired-path>
|
||||
const isNew = searchParams.get('new') === 'true'
|
||||
const [newPath, setNewPath] = useState(searchParams.get('path') ?? '')
|
||||
|
||||
const ref = searchParams.get('ref') ?? ''
|
||||
const filePath = searchParams.get('path') ?? ''
|
||||
const filePath = isNew ? newPath : (searchParams.get('path') ?? '')
|
||||
const fileName = filePath.split('/').pop() ?? filePath
|
||||
const fileExt = fileName.includes('.') ? fileName.split('.').pop() ?? '' : ''
|
||||
|
||||
const { data: repo } = useRepo(owner, repoName)
|
||||
const { data: blob, isLoading, isError } = useRepoBlob(owner, repoName, ref, filePath)
|
||||
const { data: blob, isLoading, isError } = useRepoBlob(owner, repoName, ref, isNew ? '' : filePath)
|
||||
const updateBlob = useUpdateBlob(owner, repoName)
|
||||
|
||||
const branch = ref || repo?.defaultBranch || 'main'
|
||||
@@ -33,30 +40,55 @@ export default function BlobPage() {
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
if (isNew) {
|
||||
navigate(-1)
|
||||
} else {
|
||||
setEditing(false)
|
||||
setPreview(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommit() {
|
||||
if (!commitMsg.trim() || !filePath) return
|
||||
const path = isNew ? newPath.trim() : filePath
|
||||
if (!commitMsg.trim() || !path) return
|
||||
await updateBlob.mutateAsync({
|
||||
path: filePath,
|
||||
path,
|
||||
content: editContent,
|
||||
message: commitMsg.trim(),
|
||||
branch,
|
||||
})
|
||||
setEditing(false)
|
||||
navigate(`/repos/${owner}/${repoName}/blob?ref=${encodeURIComponent(branch)}&path=${encodeURIComponent(filePath)}`, { replace: true })
|
||||
navigate(`/repos/${owner}/${repoName}/blob?ref=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}`, { replace: true })
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div>
|
||||
if (isError || !blob) return <div className="p-6 text-sm text-[var(--c-danger)]">File not found.</div>
|
||||
const isEditingState = editing || isNew
|
||||
|
||||
const lines = blob.content.split('\n')
|
||||
const pathParts = filePath.split('/')
|
||||
// For new file, start in edit mode with empty content.
|
||||
if (isNew && !editing && editContent === '') {
|
||||
setEditContent('')
|
||||
setCommitMsg('Add new file')
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const pathParts = filePath.split('/').filter(Boolean)
|
||||
const content = blob?.content ?? ''
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4">
|
||||
<div className="flex h-full min-h-0" style={{ height: 'calc(100vh - 56px)' }}>
|
||||
|
||||
{/* Left file tree — hidden on small screens */}
|
||||
<div className="hidden md:flex">
|
||||
<FileSideTree
|
||||
owner={owner}
|
||||
repo={repoName}
|
||||
branch={branch}
|
||||
activePath={filePath}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0 overflow-auto">
|
||||
<div className="w-full px-4 md:px-6 py-6 space-y-4">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-sm flex-wrap">
|
||||
@@ -76,21 +108,35 @@ export default function BlobPage() {
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{isNew && <span className="text-[var(--c-muted)] font-semibold">New file</span>}
|
||||
</div>
|
||||
|
||||
{/* File card */}
|
||||
{isLoading && !isNew && <RepoListSkeleton />}
|
||||
{(isError && !isNew) && <div className="text-sm text-[var(--c-danger)] p-4">File not found.</div>}
|
||||
|
||||
{(!isLoading || isNew) && (
|
||||
<div className="border border-[var(--c-border)] rounded bg-[var(--c-surface)] overflow-hidden">
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] 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-[var(--c-border)] rounded text-xs text-[var(--c-muted)] bg-[var(--c-surface)]">
|
||||
<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>
|
||||
{isNew ? (
|
||||
<input
|
||||
value={newPath}
|
||||
onChange={e => setNewPath(e.target.value)}
|
||||
placeholder="path/to/new-file.ts"
|
||||
className="border border-[var(--c-border)] rounded px-2 py-0.5 text-xs focus:outline-none focus:border-[var(--c-brand-focus)] text-[var(--c-text)] bg-[var(--c-surface)]"
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[var(--c-muted)]">{repoName}</span>
|
||||
<span className="text-[var(--c-muted)]">/</span>
|
||||
<span className="font-medium text-[var(--c-text)]">{fileName}</span>
|
||||
@@ -103,9 +149,11 @@ export default function BlobPage() {
|
||||
<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 && (
|
||||
{!isEditingState && (
|
||||
<div className="flex items-center gap-1">
|
||||
{isMarkdown && (
|
||||
<button
|
||||
@@ -125,7 +173,7 @@ export default function BlobPage() {
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(blob.content)}
|
||||
onClick={() => navigator.clipboard.writeText(content)}
|
||||
className="px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:bg-[var(--c-surface-muted)]"
|
||||
>
|
||||
Copy
|
||||
@@ -134,15 +182,14 @@ export default function BlobPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{editing ? (
|
||||
{/* Content area */}
|
||||
{isEditingState ? (
|
||||
<div className="flex flex-col">
|
||||
<textarea
|
||||
<CodeEditor
|
||||
value={editContent}
|
||||
onChange={e => setEditContent(e.target.value)}
|
||||
className="w-full font-mono text-xs text-[var(--c-text)] bg-[var(--c-surface)] p-4 resize-none focus:outline-none border-b border-[var(--c-border)]"
|
||||
style={{ minHeight: Math.max(300, lines.length * 20) }}
|
||||
spellCheck={false}
|
||||
onChange={setEditContent}
|
||||
language={isNew ? (newPath.split('.').pop() ?? '') : fileExt}
|
||||
minHeight="400px"
|
||||
/>
|
||||
<div className="p-4 bg-[var(--c-surface-raised)] border-t border-[var(--c-border)] space-y-3">
|
||||
<div>
|
||||
@@ -157,10 +204,10 @@ export default function BlobPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={updateBlob.isPending || !commitMsg.trim()}
|
||||
disabled={updateBlob.isPending || !commitMsg.trim() || (isNew && !newPath.trim())}
|
||||
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
|
||||
>
|
||||
{updateBlob.isPending ? 'Committing…' : 'Commit changes'}
|
||||
{updateBlob.isPending ? 'Committing…' : isNew ? 'Create file' : 'Commit changes'}
|
||||
</button>
|
||||
<button onClick={cancelEdit} className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
|
||||
Cancel
|
||||
@@ -176,25 +223,20 @@ export default function BlobPage() {
|
||||
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-pre:bg-[var(--c-surface-muted)] prose-pre:border prose-pre:border-[var(--c-border)] prose-pre:rounded">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{blob.content}</ReactMarkdown>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{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-[var(--c-muted)] px-4 py-0.5 w-12 border-r border-[var(--c-border)] bg-[var(--c-surface-raised)] sticky left-0">
|
||||
{i + 1}
|
||||
</td>
|
||||
<td className="px-4 py-0.5 text-[var(--c-text)] whitespace-pre">{line || ' '}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<CodeEditor
|
||||
value={content}
|
||||
language={fileExt}
|
||||
readOnly
|
||||
minHeight="400px"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queri
|
||||
import { useEnvironments } from '../api/queries/environments'
|
||||
import { useInstance } from '../api/queries/instance'
|
||||
import { TreeBrowser } from '../components/repos/TreeBrowser'
|
||||
import { RepoContextPanel } from '../components/repos/RepoContextPanel'
|
||||
import { RepoFileSearch } from '../components/repos/RepoFileSearch'
|
||||
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||
import { RepoAvatar } from '../ui/RepoAvatar'
|
||||
import { useRecentRepos } from '../hooks/useRecentRepos'
|
||||
@@ -16,6 +18,7 @@ export default function RepoPage() {
|
||||
const [showBranches, setShowBranches] = useState(false)
|
||||
const [showClone, setShowClone] = useState(false)
|
||||
const [cloneTab, setCloneTab] = useState<'https' | 'ssh'>('https')
|
||||
const [cloneCopied, setCloneCopied] = useState(false)
|
||||
const branchRef = useRef<HTMLDivElement>(null)
|
||||
const cloneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -45,8 +48,8 @@ export default function RepoPage() {
|
||||
const branch = ref || repo.defaultBranch
|
||||
const cloneUrl = `${window.location.origin}/${owner}/${repoName}.git`
|
||||
|
||||
const sshHost = instance?.sshHost ?? window.location.hostname
|
||||
const sshPort = instance?.sshPort ?? '2222'
|
||||
const sshHost = instance?.sshHost || window.location.hostname
|
||||
const sshPort = instance?.sshPort || '2222'
|
||||
const sshUrl = sshPort === '22'
|
||||
? `git@${sshHost}:${owner}/${repoName}.git`
|
||||
: `ssh://git@${sshHost}:${sshPort}/${owner}/${repoName}.git`
|
||||
@@ -59,7 +62,9 @@ export default function RepoPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4">
|
||||
<div className="max-w-[1400px] mx-auto px-4 md:px-6 py-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
@@ -158,15 +163,24 @@ export default function RepoPage() {
|
||||
{cloneTab === 'https' ? cloneUrl : sshUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(cloneTab === 'https' ? cloneUrl : sshUrl)}
|
||||
className="text-[10px] text-[var(--c-brand)] hover:underline shrink-0"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(cloneTab === 'https' ? cloneUrl : sshUrl)
|
||||
setCloneCopied(true)
|
||||
setTimeout(() => setCloneCopied(false), 1500)
|
||||
}}
|
||||
className={`text-[10px] font-medium shrink-0 transition-colors ${
|
||||
cloneCopied
|
||||
? 'text-[var(--c-success)]'
|
||||
: 'text-[var(--c-brand)] hover:underline'
|
||||
}`}
|
||||
>
|
||||
Copy
|
||||
{cloneCopied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
{cloneTab === 'ssh' && (
|
||||
<p className="text-[10px] text-[var(--c-muted)] mt-1.5">
|
||||
Requires an SSH key added to your account settings.
|
||||
Requires an SSH key added to your{' '}
|
||||
<Link to="/settings" className="text-[var(--c-brand)] hover:underline">account settings</Link>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -261,6 +275,15 @@ export default function RepoPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right sidebar — hidden below lg breakpoint */}
|
||||
<div className="w-72 shrink-0 hidden lg:block space-y-3">
|
||||
<RepoFileSearch owner={owner} repo={repoName} branch={branch} />
|
||||
<RepoContextPanel owner={owner} repo={repoName} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"xorm.io/xorm"
|
||||
|
||||
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
||||
)
|
||||
|
||||
type InsightsHandler struct {
|
||||
db *xorm.Engine
|
||||
}
|
||||
|
||||
func NewInsightsHandler(db *xorm.Engine) *InsightsHandler {
|
||||
return &InsightsHandler{db: db}
|
||||
}
|
||||
|
||||
type insightsResponse struct {
|
||||
Languages []gitdomain.LangStat `json:"languages"`
|
||||
Contributors []gitdomain.Contributor `json:"contributors"`
|
||||
TotalCommits int `json:"totalCommits"`
|
||||
}
|
||||
|
||||
// Get returns language breakdown, top contributors, and total commit count
|
||||
// for a repository. All data is read directly from git — no DB writes.
|
||||
func (h *InsightsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
repo, ok := resolveRepo(h.db, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
langs, _ := gitdomain.LanguageStats(repo.DiskPath, repo.DefaultBranch)
|
||||
contribs, _ := gitdomain.Contributors(repo.DiskPath, 8)
|
||||
count, _ := gitdomain.CommitCount(repo.DiskPath)
|
||||
|
||||
if langs == nil {
|
||||
langs = []gitdomain.LangStat{}
|
||||
}
|
||||
if contribs == nil {
|
||||
contribs = []gitdomain.Contributor{}
|
||||
}
|
||||
|
||||
jsonOK(w, insightsResponse{
|
||||
Languages: langs,
|
||||
Contributors: contribs,
|
||||
TotalCommits: count,
|
||||
})
|
||||
}
|
||||
@@ -28,9 +28,12 @@ func (h *InstanceHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// sshHost extracts the hostname from InstanceURL. Falls back to the request
|
||||
// host when InstanceURL is unset (common in local development).
|
||||
// sshHost resolves the SSH hostname to display in clone URLs.
|
||||
// Priority: SSH_HOST env var > InstanceURL hostname > request Host header > localhost.
|
||||
func (h *InstanceHandler) sshHost(r *http.Request) string {
|
||||
if h.cfg.SSHHost != "" {
|
||||
return h.cfg.SSHHost
|
||||
}
|
||||
if h.cfg.InstanceURL != "" {
|
||||
if u, err := url.Parse(h.cfg.InstanceURL); err == nil && u.Hostname() != "" {
|
||||
return u.Hostname()
|
||||
@@ -41,5 +44,5 @@ func (h *InstanceHandler) sshHost(r *http.Request) string {
|
||||
if u, err := url.Parse("http://" + host); err == nil {
|
||||
return u.Hostname()
|
||||
}
|
||||
return host
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -650,3 +653,148 @@ func (h *RepoHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*model
|
||||
jsonError(w, "repository not found", http.StatusNotFound)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// SearchFiles handles GET /repos/{owner}/{repo}/files?q=...&ref=...
|
||||
// Returns up to 20 matching file paths (case-insensitive substring match).
|
||||
// When q is empty, returns all file paths up to 500 (used by the sidebar tree).
|
||||
func (h *RepoHandler) SearchFiles(w http.ResponseWriter, r *http.Request) {
|
||||
repo, ok := h.lookupRepo(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
ref := r.URL.Query().Get("ref")
|
||||
if ref == "" {
|
||||
ref = repo.DefaultBranch
|
||||
}
|
||||
|
||||
limit := 20
|
||||
if query == "" {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
files, err := gitdomain.SearchFiles(repo.DiskPath, ref, query, limit)
|
||||
if err != nil {
|
||||
jsonError(w, "search failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if files == nil {
|
||||
files = []string{}
|
||||
}
|
||||
jsonOK(w, files)
|
||||
}
|
||||
|
||||
// UploadFiles handles POST /repos/{owner}/{repo}/upload — multipart upload.
|
||||
// Accepts multiple regular files (field "file[]") and/or a ZIP archive (field "zip").
|
||||
// All files are committed in a single git commit.
|
||||
func (h *RepoHandler) UploadFiles(w http.ResponseWriter, r *http.Request) {
|
||||
repo, ok := h.lookupRepo(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
username, _ := r.Context().Value(middleware.ContextKeyUsername).(string)
|
||||
if !HasPermission(h.db, repo, username, "write") {
|
||||
jsonError(w, "you do not have write access to this repository", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
const maxUpload = 50 << 20 // 50 MB
|
||||
if err := r.ParseMultipartForm(maxUpload); err != nil {
|
||||
jsonError(w, "could not parse upload: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
branch := r.FormValue("branch")
|
||||
if branch == "" {
|
||||
branch = repo.DefaultBranch
|
||||
}
|
||||
message := r.FormValue("message")
|
||||
if message == "" {
|
||||
message = "Upload files"
|
||||
}
|
||||
|
||||
var uploads []gitdomain.FileUpload
|
||||
|
||||
// Regular files (field "file[]" or "file"). Browser sends webkitRelativePath
|
||||
// via the custom header X-File-Path; fall back to the bare filename.
|
||||
for _, fhs := range r.MultipartForm.File {
|
||||
for _, fh := range fhs {
|
||||
if fh.Size == 0 {
|
||||
continue
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, 10<<20)) // 10 MB per file
|
||||
f.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Prefer the relative path sent by the browser (folder upload),
|
||||
// otherwise use the bare filename.
|
||||
relPath := fh.Filename
|
||||
if rp := fh.Header.Get("X-File-Path"); rp != "" {
|
||||
relPath = rp
|
||||
}
|
||||
if strings.EqualFold(fh.Header.Get("Content-Disposition"), "") {
|
||||
// Skip the "zip" field — handled separately below.
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(relPath))
|
||||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||||
jsonError(w, fmt.Sprintf("invalid path: %s", relPath), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
uploads = append(uploads, gitdomain.FileUpload{Path: clean, Content: data})
|
||||
}
|
||||
}
|
||||
|
||||
// ZIP archive (field "zip").
|
||||
if zipFHs, ok := r.MultipartForm.File["zip"]; ok && len(zipFHs) > 0 {
|
||||
fh := zipFHs[0]
|
||||
f, err := fh.Open()
|
||||
if err == nil {
|
||||
zipData, err := io.ReadAll(io.LimitReader(f, maxUpload))
|
||||
f.Close()
|
||||
if err == nil {
|
||||
zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
|
||||
if err == nil {
|
||||
for _, zf := range zr.File {
|
||||
if zf.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(zf.Name))
|
||||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||||
continue
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(rc, 10<<20))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
uploads = append(uploads, gitdomain.FileUpload{Path: clean, Content: data})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(uploads) == 0 {
|
||||
jsonError(w, "no files found in upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := gitdomain.WriteManyFiles(repo.DiskPath, branch, message, username, username+"@forgebucket", uploads); err != nil {
|
||||
jsonError(w, "commit failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]int{"committed": len(uploads)}) //nolint:errcheck
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
||||
vulnH := handlers.NewVulnScanHandler(engine, vulnScanner)
|
||||
archiveH := handlers.NewArchiveHandler(engine)
|
||||
instanceH := handlers.NewInstanceHandler(cfg)
|
||||
insightsH := handlers.NewInsightsHandler(engine)
|
||||
|
||||
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
||||
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
|
||||
@@ -181,6 +182,9 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
||||
r.Get("/commits", repoH.Commits)
|
||||
r.Get("/branches", repoH.Branches)
|
||||
r.Get("/archive", archiveH.Download)
|
||||
r.Get("/insights", insightsH.Get)
|
||||
r.Get("/files", repoH.SearchFiles)
|
||||
r.With(csrf).Post("/upload", repoH.UploadFiles)
|
||||
r.Get("/diff", repoH.Diff)
|
||||
r.Route("/pulls", func(r chi.Router) {
|
||||
r.Get("/", prH.List)
|
||||
|
||||
@@ -45,6 +45,7 @@ type Config struct {
|
||||
OCIRoot string
|
||||
|
||||
// SSH server
|
||||
SSHHost string // env: SSH_HOST, empty = auto-detect from request/instance URL
|
||||
SSHPort string // env: SSH_PORT, default "2222"
|
||||
SSHHostKeyPath string // env: SSH_HOST_KEY_PATH, empty = generate ephemeral
|
||||
|
||||
@@ -72,6 +73,7 @@ func Load() (*Config, error) {
|
||||
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
|
||||
cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing)
|
||||
|
||||
cfg.SSHHost = os.Getenv("SSH_HOST")
|
||||
cfg.SSHPort = getEnv("SSH_PORT", "2222")
|
||||
cfg.SSHHostKeyPath = os.Getenv("SSH_HOST_KEY_PATH")
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -251,6 +253,84 @@ func WriteFile(repoPath, branch, filePath, content, authorName, authorEmail, mes
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileUpload holds a file path and its content for a batch commit.
|
||||
type FileUpload struct {
|
||||
Path string // repo-relative path, e.g. "src/main.go"
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// WriteManyFiles commits all files in a single commit to branch. Each file path
|
||||
// must be a clean relative path — no ".." or absolute paths allowed.
|
||||
func WriteManyFiles(repoPath, branch, message, authorName, authorEmail string, files []FileUpload) error {
|
||||
if len(files) == 0 {
|
||||
return errors.New("no files to commit")
|
||||
}
|
||||
|
||||
// Validate all paths before touching the filesystem.
|
||||
for _, f := range files {
|
||||
clean := filepath.Clean(filepath.FromSlash(f.Path))
|
||||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||||
return fmt.Errorf("invalid file path: %s", f.Path)
|
||||
}
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "fb-upload-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("mktemp: %w", err)
|
||||
}
|
||||
|
||||
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
|
||||
authorEnv := append(baseEnv,
|
||||
"GIT_AUTHOR_NAME="+authorName,
|
||||
"GIT_AUTHOR_EMAIL="+authorEmail,
|
||||
"GIT_COMMITTER_NAME="+authorName,
|
||||
"GIT_COMMITTER_EMAIL="+authorEmail,
|
||||
)
|
||||
|
||||
addWt := exec.Command("git", "worktree", "add", "--force", tmpDir, branch)
|
||||
addWt.Dir = filepath.Clean(repoPath)
|
||||
addWt.Env = baseEnv
|
||||
if out, err := addWt.CombinedOutput(); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
return fmt.Errorf("worktree add: %w: %s", err, out)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
rmWt := exec.Command("git", "worktree", "remove", "--force", tmpDir)
|
||||
rmWt.Dir = filepath.Clean(repoPath)
|
||||
rmWt.Env = baseEnv
|
||||
rmWt.Run()
|
||||
os.RemoveAll(tmpDir)
|
||||
}()
|
||||
|
||||
for _, f := range files {
|
||||
clean := filepath.Clean(filepath.FromSlash(f.Path))
|
||||
fullPath := filepath.Join(tmpDir, clean)
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||||
return fmt.Errorf("mkdirall %s: %w", clean, err)
|
||||
}
|
||||
if err := os.WriteFile(fullPath, f.Content, 0644); err != nil {
|
||||
return fmt.Errorf("writefile %s: %w", clean, err)
|
||||
}
|
||||
}
|
||||
|
||||
addC := exec.Command("git", "add", ".")
|
||||
addC.Dir = tmpDir
|
||||
addC.Env = authorEnv
|
||||
if out, err := addC.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git add: %w: %s", err, out)
|
||||
}
|
||||
|
||||
commitC := exec.Command("git", "commit", "-m", message)
|
||||
commitC.Dir = tmpDir
|
||||
commitC.Env = authorEnv
|
||||
if out, err := commitC.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git commit: %w: %s", err, out)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Branch struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -508,3 +588,197 @@ func ArchiveStream(repoPath string, ref string, format string, w io.Writer) erro
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Language stats ─────────────────────────────────────────────────────────────
|
||||
|
||||
// LangStat holds the aggregate file-count and percentage for one language.
|
||||
type LangStat struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Count int `json:"count"`
|
||||
Pct float64 `json:"pct"`
|
||||
}
|
||||
|
||||
// extLang maps file extensions to (display name, hex color).
|
||||
var extLang = map[string][2]string{
|
||||
".go": {"Go", "#00ADD8"},
|
||||
".ts": {"TypeScript", "#3178C6"},
|
||||
".tsx": {"TypeScript", "#3178C6"},
|
||||
".js": {"JavaScript", "#F7DF1E"},
|
||||
".jsx": {"JavaScript", "#F7DF1E"},
|
||||
".mjs": {"JavaScript", "#F7DF1E"},
|
||||
".py": {"Python", "#3572A5"},
|
||||
".rb": {"Ruby", "#CC342D"},
|
||||
".rs": {"Rust", "#DEA584"},
|
||||
".java": {"Java", "#B07219"},
|
||||
".cs": {"C#", "#178600"},
|
||||
".cpp": {"C++", "#F34B7D"},
|
||||
".cc": {"C++", "#F34B7D"},
|
||||
".c": {"C", "#555555"},
|
||||
".h": {"C", "#555555"},
|
||||
".swift": {"Swift", "#F05138"},
|
||||
".kt": {"Kotlin", "#A97BFF"},
|
||||
".php": {"PHP", "#4F5D95"},
|
||||
".html": {"HTML", "#E34C26"},
|
||||
".css": {"CSS", "#563D7C"},
|
||||
".scss": {"SCSS", "#C6538C"},
|
||||
".sql": {"SQL", "#e38c00"},
|
||||
".sh": {"Shell", "#89E051"},
|
||||
".bash": {"Shell", "#89E051"},
|
||||
".yaml": {"YAML", "#CB171E"},
|
||||
".yml": {"YAML", "#CB171E"},
|
||||
".json": {"JSON", "#292929"},
|
||||
".md": {"Markdown", "#083FA1"},
|
||||
".tf": {"HCL", "#844FBA"},
|
||||
".proto": {"Protobuf", "#5F5CE9"},
|
||||
".dart": {"Dart", "#00B4AB"},
|
||||
".lua": {"Lua", "#000080"},
|
||||
".r": {"R", "#198CE7"},
|
||||
}
|
||||
|
||||
// LanguageStats analyses the file tree at ref and returns language breakdown
|
||||
// sorted by file count descending. Languages under 3% are collapsed into "Other".
|
||||
func LanguageStats(repoPath, ref string) ([]LangStat, error) {
|
||||
if ref == "" {
|
||||
ref = "HEAD"
|
||||
}
|
||||
out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
counts := map[string]int{}
|
||||
total := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(line))
|
||||
lang, ok := extLang[ext]
|
||||
if ok {
|
||||
counts[lang[0]]++
|
||||
} else {
|
||||
counts["Other"]++
|
||||
}
|
||||
total++
|
||||
}
|
||||
if total == 0 {
|
||||
return []LangStat{}, nil
|
||||
}
|
||||
|
||||
// Aggregate by name with color lookup.
|
||||
colorOf := map[string]string{"Other": "#8B8B8B"}
|
||||
for _, v := range extLang {
|
||||
colorOf[v[0]] = v[1]
|
||||
}
|
||||
|
||||
var stats []LangStat
|
||||
otherCount := 0
|
||||
for name, count := range counts {
|
||||
pct := float64(count) / float64(total) * 100
|
||||
if name == "Other" || pct < 3.0 {
|
||||
otherCount += count
|
||||
continue
|
||||
}
|
||||
stats = append(stats, LangStat{Name: name, Color: colorOf[name], Count: count, Pct: pct})
|
||||
}
|
||||
sort.Slice(stats, func(i, j int) bool { return stats[i].Count > stats[j].Count })
|
||||
if len(stats) > 10 {
|
||||
for _, s := range stats[10:] {
|
||||
otherCount += s.Count
|
||||
}
|
||||
stats = stats[:10]
|
||||
}
|
||||
if otherCount > 0 {
|
||||
stats = append(stats, LangStat{
|
||||
Name: "Other",
|
||||
Color: "#8B8B8B",
|
||||
Count: otherCount,
|
||||
Pct: float64(otherCount) / float64(total) * 100,
|
||||
})
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ── Contributor stats ─────────────────────────────────────────────────────────
|
||||
|
||||
// Contributor holds a commit author name and their commit count.
|
||||
type Contributor struct {
|
||||
Name string `json:"name"`
|
||||
Commits int `json:"commits"`
|
||||
}
|
||||
|
||||
// Contributors returns the top limit commit authors sorted by commit count.
|
||||
// Uses git shortlog which is fast even on large repos.
|
||||
func Contributors(repoPath string, limit int) ([]Contributor, error) {
|
||||
out, err := run(repoPath, "shortlog", "-sn", "--no-merges", "HEAD")
|
||||
if err != nil {
|
||||
return []Contributor{}, nil // empty repo or detached HEAD — not an error
|
||||
}
|
||||
|
||||
var result []Contributor
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Format: " 42\tAlice Wang"
|
||||
idx := strings.Index(line, "\t")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
countStr := strings.TrimSpace(line[:idx])
|
||||
name := strings.TrimSpace(line[idx+1:])
|
||||
n, err := strconv.Atoi(countStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, Contributor{Name: name, Commits: n})
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CommitCount returns the total number of commits reachable from HEAD.
|
||||
func CommitCount(repoPath string) (int, error) {
|
||||
out, err := run(repoPath, "rev-list", "--count", "HEAD")
|
||||
if err != nil {
|
||||
return 0, nil // empty repo
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SearchFiles returns file paths matching query (case-insensitive substring)
|
||||
// in the repository tree at ref, capped at limit results.
|
||||
func SearchFiles(repoPath, ref, query string, limit int) ([]string, error) {
|
||||
if ref == "" {
|
||||
ref = "HEAD"
|
||||
}
|
||||
out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref)
|
||||
if err != nil {
|
||||
return []string{}, nil // empty repo
|
||||
}
|
||||
|
||||
lower := strings.ToLower(query)
|
||||
var results []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToLower(line), lower) {
|
||||
results = append(results, line)
|
||||
if len(results) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user