phase 3 initial completion
This commit is contained in:
+36
-21
@@ -2,7 +2,8 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { AppShell } from './components/layout/AppShell'
|
import { AppShell } from './components/layout/AppShell'
|
||||||
import { RepoListSkeleton } from './ui/Skeleton'
|
import { RepoListSkeleton } from './ui/Skeleton'
|
||||||
import { Suspense, lazy } from 'react'
|
import { Suspense, lazy, useEffect } from 'react'
|
||||||
|
import { bootstrapCSRF } from './api/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -15,36 +16,50 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
// Pages — code-split per route
|
||||||
const ReposPage = lazy(() => import('./pages/ReposPage'))
|
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
||||||
const PRsPage = lazy(() => import('./pages/PRsPage'))
|
const ReposPage = lazy(() => import('./pages/ReposPage'))
|
||||||
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
|
const RepoPage = lazy(() => import('./pages/RepoPage'))
|
||||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
|
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
|
||||||
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
|
const PRDetailPage = lazy(() => import('./pages/PRDetailPage'))
|
||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
const PRsPage = lazy(() => import('./pages/PRsPage'))
|
||||||
|
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
|
||||||
|
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
|
||||||
|
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
|
||||||
|
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
||||||
|
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
return (
|
return <div className="p-6"><RepoListSkeleton /></div>
|
||||||
<div className="p-6">
|
}
|
||||||
<RepoListSkeleton />
|
|
||||||
</div>
|
function S({ children }: { children: React.ReactNode }) {
|
||||||
)
|
return <Suspense fallback={<PageLoader />}>{children}</Suspense>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primes the CSRF cookie once when the SPA mounts
|
||||||
|
function CSRFBootstrap() {
|
||||||
|
useEffect(() => { bootstrapCSRF() }, [])
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<CSRFBootstrap />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route index element={<Suspense fallback={<PageLoader />}><DashboardPage /></Suspense>} />
|
<Route index element={<S><DashboardPage /></S>} />
|
||||||
<Route path="repos" element={<Suspense fallback={<PageLoader />}><ReposPage /></Suspense>} />
|
<Route path="repos" element={<S><ReposPage /></S>} />
|
||||||
<Route path="pulls" element={<Suspense fallback={<PageLoader />}><PRsPage /></Suspense>} />
|
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
|
||||||
<Route path="pipelines" element={<Suspense fallback={<PageLoader />}><PipelinesPage /></Suspense>} />
|
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
|
||||||
<Route path="explore" element={<Suspense fallback={<PageLoader />}><ExplorePage /></Suspense>} />
|
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
|
||||||
<Route path="profile" element={<Suspense fallback={<PageLoader />}><ProfilePage /></Suspense>} />
|
<Route path="pulls" element={<S><PRsPage /></S>} />
|
||||||
<Route path="settings" element={<Suspense fallback={<PageLoader />}><SettingsPage /></Suspense>} />
|
<Route path="pipelines" element={<S><PipelinesPage /></S>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="explore" element={<S><ExplorePage /></S>} />
|
||||||
|
<Route path="profile" element={<S><ProfilePage /></S>} />
|
||||||
|
<Route path="settings" element={<S><SettingsPage /></S>} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -2,12 +2,20 @@ import { z } from 'zod'
|
|||||||
|
|
||||||
let csrfToken: string | null = null
|
let csrfToken: string | null = null
|
||||||
|
|
||||||
|
// Called once on app bootstrap. Fetches the CSRF token and sets the cookie.
|
||||||
|
export async function bootstrapCSRF(): Promise<void> {
|
||||||
|
const res = await fetch('/api/v1/csrf', { credentials: 'include' })
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
if (typeof data.token === 'string') {
|
||||||
|
csrfToken = data.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getCSRFToken(): Promise<string> {
|
async function getCSRFToken(): Promise<string> {
|
||||||
if (csrfToken) return csrfToken
|
if (csrfToken) return csrfToken
|
||||||
const res = await fetch('/api/v1/csrf', { credentials: 'include' })
|
await bootstrapCSRF()
|
||||||
if (!res.ok) throw new Error('Failed to fetch CSRF token')
|
return csrfToken ?? ''
|
||||||
csrfToken = res.headers.get('X-CSRF-Token') ?? ''
|
|
||||||
return csrfToken
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { z } from 'zod'
|
|||||||
import { api } from '../client'
|
import { api } from '../client'
|
||||||
import type { Repository, TreeEntry } from '../../types/api'
|
import type { Repository, TreeEntry } from '../../types/api'
|
||||||
|
|
||||||
|
const fileDiffSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
oldPath: z.string().optional(),
|
||||||
|
additions: z.number(),
|
||||||
|
deletions: z.number(),
|
||||||
|
patch: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileDiffsSchema = z.array(fileDiffSchema)
|
||||||
|
|
||||||
const repositorySchema = z.object({
|
const repositorySchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
ownerId: z.number(),
|
ownerId: z.number(),
|
||||||
@@ -53,6 +63,15 @@ export function useRepoTree(owner: string, name: string, ref: string, path = '')
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRepoDiff(owner: string, name: string, base: string, head: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['repos', owner, name, 'diff', base, head],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get(`/api/v1/repos/${owner}/${name}/diff?base=${base}&head=${head}`, fileDiffsSchema),
|
||||||
|
enabled: Boolean(owner && name && base && head),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateRepo() {
|
export function useCreateRepo() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import type { Pipeline } from '../../types/api'
|
||||||
|
|
||||||
|
interface Stage {
|
||||||
|
name: string
|
||||||
|
status: Pipeline['status']
|
||||||
|
duration?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineWaterfallProps {
|
||||||
|
pipeline: Pipeline
|
||||||
|
stages?: Stage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<Pipeline['status'], string> = {
|
||||||
|
pending: 'bg-[#F4F5F7] border-[#DFE1E6] text-[#5E6C84]',
|
||||||
|
running: 'bg-[#DEEBFF] border-[#4C9AFF] text-[#0052CC]',
|
||||||
|
success: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]',
|
||||||
|
failure: 'bg-[#FFEBE6] border-[#FF8F73] text-[#BF2600]',
|
||||||
|
cancelled: 'bg-[#F4F5F7] border-[#DFE1E6] text-[#5E6C84]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_DOT: Record<Pipeline['status'], string> = {
|
||||||
|
pending: 'bg-[#97A0AF]',
|
||||||
|
running: 'bg-[#0052CC] animate-pulse',
|
||||||
|
success: 'bg-[#00875A]',
|
||||||
|
failure: 'bg-[#DE350B]',
|
||||||
|
cancelled: 'bg-[#97A0AF]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<Pipeline['status'], string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
running: 'Running',
|
||||||
|
success: 'Passed',
|
||||||
|
failure: 'Failed',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default stage breakdown when no stage data is provided
|
||||||
|
function defaultStages(status: Pipeline['status']): Stage[] {
|
||||||
|
const stages: Array<{ name: string; order: number }> = [
|
||||||
|
{ name: 'Clone', order: 0 },
|
||||||
|
{ name: 'Build', order: 1 },
|
||||||
|
{ name: 'Test', order: 2 },
|
||||||
|
{ name: 'Deploy', order: 3 },
|
||||||
|
]
|
||||||
|
return stages.map((s, i) => ({
|
||||||
|
name: s.name,
|
||||||
|
status: deriveStageStatus(status, i, stages.length),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveStageStatus(pipelineStatus: Pipeline['status'], idx: number, total: number): Pipeline['status'] {
|
||||||
|
if (pipelineStatus === 'success') return 'success'
|
||||||
|
if (pipelineStatus === 'pending') return 'pending'
|
||||||
|
if (pipelineStatus === 'cancelled') return idx === 0 ? 'cancelled' : 'pending'
|
||||||
|
if (pipelineStatus === 'failure') {
|
||||||
|
const failAt = Math.floor(total * 0.6)
|
||||||
|
if (idx < failAt) return 'success'
|
||||||
|
if (idx === failAt) return 'failure'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
// running
|
||||||
|
const runAt = Math.floor(total * 0.4)
|
||||||
|
if (idx < runAt) return 'success'
|
||||||
|
if (idx === runAt) return 'running'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PipelineWaterfall({ pipeline, stages }: PipelineWaterfallProps) {
|
||||||
|
const resolvedStages = stages ?? defaultStages(pipeline.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-[#DFE1E6] rounded p-4 bg-white">
|
||||||
|
{/* Pipeline header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('w-2.5 h-2.5 rounded-full shrink-0', STATUS_DOT[pipeline.status])} />
|
||||||
|
<span className="text-sm font-semibold text-[#172B4D]">
|
||||||
|
Pipeline #{pipeline.id}
|
||||||
|
</span>
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs font-medium px-2 py-0.5 rounded-full border',
|
||||||
|
STATUS_COLOR[pipeline.status],
|
||||||
|
)}>
|
||||||
|
{STATUS_LABEL[pipeline.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[#5E6C84]">{pipeline.ref}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Waterfall stages */}
|
||||||
|
<div className="flex items-center gap-0 overflow-x-auto pb-2">
|
||||||
|
{resolvedStages.map((stage, i) => (
|
||||||
|
<div key={stage.name} className="flex items-center shrink-0">
|
||||||
|
{/* Stage box */}
|
||||||
|
<div className={cn(
|
||||||
|
'flex flex-col items-center justify-center px-4 py-3 rounded border text-center min-w-[80px]',
|
||||||
|
STATUS_COLOR[stage.status],
|
||||||
|
)}>
|
||||||
|
<StatusIcon status={stage.status} />
|
||||||
|
<span className="text-[11px] font-semibold mt-1">{stage.name}</span>
|
||||||
|
{stage.duration && (
|
||||||
|
<span className="text-[10px] opacity-70 mt-0.5">{stage.duration}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connector arrow (not after last) */}
|
||||||
|
{i < resolvedStages.length - 1 && (
|
||||||
|
<div className="flex items-center px-1">
|
||||||
|
<div className="h-px w-4 bg-[#DFE1E6]" />
|
||||||
|
<svg width="6" height="8" viewBox="0 0 6 8" fill="#DFE1E6">
|
||||||
|
<path d="M0 0l6 4-6 4V0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIcon({ status }: { status: Pipeline['status'] }) {
|
||||||
|
if (status === 'success') {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" fill="none" stroke="#00875A" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === 'failure') {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" fill="none" stroke="#DE350B" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === 'running') {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" fill="none" stroke="#0052CC" strokeWidth="2" viewBox="0 0 24 24" className="animate-spin">
|
||||||
|
<path strokeLinecap="round" d="M12 3a9 9 0 1 0 9 9" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
export interface FileDiff {
|
||||||
|
path: string
|
||||||
|
oldPath?: string
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
patch: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffViewerProps {
|
||||||
|
files: FileDiff[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffViewer({ files }: DiffViewerProps) {
|
||||||
|
if (!files.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-[#5E6C84] text-sm">
|
||||||
|
No changes in this diff.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{files.map(file => (
|
||||||
|
<FileDiffBlock key={file.path} file={file} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileDiffBlock({ file }: { file: FileDiff }) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const lines = useMemo(() => parsePatch(file.patch), [file.patch])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-[#DFE1E6] rounded overflow-hidden font-mono text-xs">
|
||||||
|
{/* File header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-[#F4F5F7] border-b border-[#DFE1E6] gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(c => !c)}
|
||||||
|
className="flex items-center gap-2 text-left min-w-0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={cn('shrink-0 transition-transform', collapsed && '-rotate-90')}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-semibold text-[#172B4D] truncate">{file.path}</span>
|
||||||
|
{file.oldPath && file.oldPath !== file.path && (
|
||||||
|
<span className="text-[#5E6C84] text-[10px] shrink-0">← {file.oldPath}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 shrink-0 text-[11px]">
|
||||||
|
{file.additions > 0 && (
|
||||||
|
<span className="text-[#00875A] font-semibold">+{file.additions}</span>
|
||||||
|
)}
|
||||||
|
{file.deletions > 0 && (
|
||||||
|
<span className="text-[#DE350B] font-semibold">-{file.deletions}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diff lines */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-[11px] leading-5">
|
||||||
|
<tbody>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<DiffLine key={i} line={line} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LineType = 'added' | 'removed' | 'context' | 'hunk'
|
||||||
|
|
||||||
|
interface ParsedLine {
|
||||||
|
type: LineType
|
||||||
|
oldNo?: number
|
||||||
|
newNo?: number
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePatch(patch: string): ParsedLine[] {
|
||||||
|
const lines: ParsedLine[] = []
|
||||||
|
let oldNo = 0
|
||||||
|
let newNo = 0
|
||||||
|
|
||||||
|
for (const raw of patch.split('\n')) {
|
||||||
|
if (raw.startsWith('@@')) {
|
||||||
|
// @@ -oldStart,oldCount +newStart,newCount @@
|
||||||
|
const match = raw.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
|
||||||
|
if (match) {
|
||||||
|
oldNo = parseInt(match[1], 10)
|
||||||
|
newNo = parseInt(match[2], 10)
|
||||||
|
}
|
||||||
|
lines.push({ type: 'hunk', content: raw })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (raw.startsWith('+') && !raw.startsWith('+++')) {
|
||||||
|
lines.push({ type: 'added', newNo: newNo++, content: raw.slice(1) })
|
||||||
|
} else if (raw.startsWith('-') && !raw.startsWith('---')) {
|
||||||
|
lines.push({ type: 'removed', oldNo: oldNo++, content: raw.slice(1) })
|
||||||
|
} else if (!raw.startsWith('\\') && !raw.startsWith('---') && !raw.startsWith('+++')) {
|
||||||
|
lines.push({ type: 'context', oldNo: oldNo++, newNo: newNo++, content: raw })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffLine({ line }: { line: ParsedLine }) {
|
||||||
|
if (line.type === 'hunk') {
|
||||||
|
return (
|
||||||
|
<tr className="bg-[#DEEBFF]">
|
||||||
|
<td className="px-2 py-0.5 text-[#5E6C84] select-none w-10 text-right" />
|
||||||
|
<td className="px-2 py-0.5 text-[#5E6C84] select-none w-10 text-right" />
|
||||||
|
<td className="px-3 py-0.5 text-[#0052CC] font-semibold whitespace-pre">{line.content}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bg = line.type === 'added'
|
||||||
|
? 'bg-[#E3FCEF]'
|
||||||
|
: line.type === 'removed'
|
||||||
|
? 'bg-[#FFEBE6]'
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const gutter = line.type === 'added'
|
||||||
|
? 'bg-[#ABF5D1] text-[#006644]'
|
||||||
|
: line.type === 'removed'
|
||||||
|
? 'bg-[#FFBDAD] text-[#BF2600]'
|
||||||
|
: 'bg-[#F4F5F7] text-[#5E6C84]'
|
||||||
|
|
||||||
|
const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={cn('group', bg)}>
|
||||||
|
<td className={cn('px-2 py-0.5 select-none w-10 text-right tabular-nums', gutter)}>
|
||||||
|
{line.type !== 'added' ? line.oldNo : ''}
|
||||||
|
</td>
|
||||||
|
<td className={cn('px-2 py-0.5 select-none w-10 text-right tabular-nums', gutter)}>
|
||||||
|
{line.type !== 'removed' ? line.newNo : ''}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-0.5 whitespace-pre">
|
||||||
|
<span className="select-none mr-2 opacity-50">{prefix}</span>
|
||||||
|
{line.content}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
interface MobileCommentProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
filePath: string
|
||||||
|
lineNumber: number
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileComment({ open, onClose, filePath, lineNumber, children }: MobileCommentProps) {
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (sheetRef.current && !sheetRef.current.contains(e.target as Node)) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
// Prevent body scroll while open
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
return () => { document.body.style.overflow = '' }
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 bg-black/40 z-40 transition-opacity duration-200',
|
||||||
|
open ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom sheet */}
|
||||||
|
<div
|
||||||
|
ref={sheetRef}
|
||||||
|
className={cn(
|
||||||
|
'fixed bottom-0 left-0 right-0 z-50 bg-white rounded-t-2xl shadow-xl',
|
||||||
|
'transition-transform duration-300 ease-out',
|
||||||
|
'pb-[env(safe-area-inset-bottom)]',
|
||||||
|
open ? 'translate-y-0' : 'translate-y-full',
|
||||||
|
)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Line comment"
|
||||||
|
>
|
||||||
|
{/* Handle */}
|
||||||
|
<div className="flex justify-center pt-3 pb-1">
|
||||||
|
<div className="w-10 h-1 rounded-full bg-[#DFE1E6]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-[#DFE1E6]">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold text-[#172B4D] truncate">{filePath}</p>
|
||||||
|
<p className="text-xs text-[#5E6C84]">Line {lineNumber}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded hover:bg-[#F4F5F7] text-[#5E6C84]"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 max-h-[60vh] overflow-y-auto">
|
||||||
|
{children ?? (
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
rows={4}
|
||||||
|
placeholder="Leave a comment on this line…"
|
||||||
|
className="w-full border border-[#DFE1E6] rounded p-3 text-sm resize-none focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded border border-[#DFE1E6] text-[#172B4D] hover:bg-[#F4F5F7] min-h-[44px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 text-sm rounded bg-[#0052CC] text-white hover:bg-[#0065FF] min-h-[44px]">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import type { Repository } from '../../types/api'
|
||||||
|
|
||||||
|
interface RepoCardProps {
|
||||||
|
repo: Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepoCard({ repo }: RepoCardProps) {
|
||||||
|
const ago = timeAgo(repo.updatedAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/repos/${repo.ownerId}/${repo.name}`}
|
||||||
|
className="flex items-start gap-4 p-4 border border-[#DFE1E6] rounded hover:border-[#4C9AFF] hover:bg-[#FAFBFC] transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="flex items-center justify-center w-9 h-9 rounded bg-[#0052CC]/10 text-[#0052CC] shrink-0">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-semibold text-[#0052CC] group-hover:underline truncate">
|
||||||
|
{repo.name}
|
||||||
|
</span>
|
||||||
|
{repo.isPrivate && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-[#DFE1E6] text-[#5E6C84] shrink-0">
|
||||||
|
Private
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{repo.description && (
|
||||||
|
<p className="text-xs text-[#5E6C84] mt-0.5 truncate">{repo.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-[#5E6C84] mt-1">
|
||||||
|
Updated {ago} · {repo.defaultBranch}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const m = Math.floor(diff / 60000)
|
||||||
|
if (m < 1) return 'just now'
|
||||||
|
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`
|
||||||
|
return new Date(iso).toLocaleDateString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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
|
||||||
|
repo: string
|
||||||
|
ref: string
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||||
|
const { data: entries, isLoading, isError } = useRepoTree(owner, repo, ref, path)
|
||||||
|
|
||||||
|
if (isLoading) return <TreeSkeleton />
|
||||||
|
if (isError) return <p className="text-xs text-[#DE350B] p-4">Failed to load file tree.</p>
|
||||||
|
if (!entries?.length) return <p className="text-xs text-[#5E6C84] p-4">Empty repository.</p>
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-[#DFE1E6] rounded overflow-hidden">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
{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>
|
||||||
|
{path.split('/').map((seg, i, arr) => {
|
||||||
|
const partial = arr.slice(0, i + 1).join('/')
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
: <span className="text-[#172B4D] font-medium">{seg}</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</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}`
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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">
|
||||||
|
<Skeleton className="w-4 h-4 shrink-0" />
|
||||||
|
<Skeleton className={`h-4 ${i % 2 === 0 ? 'w-32' : 'w-48'}`} />
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { bootstrapCSRF } from '../api/client'
|
||||||
|
|
||||||
|
// Call once at the root of the app to prime the CSRF cookie before any mutations.
|
||||||
|
export function useCSRFBootstrap() {
|
||||||
|
useEffect(() => {
|
||||||
|
bootstrapCSRF()
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -1,8 +1,56 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useRepos } from '../api/queries/repos'
|
||||||
|
import { RepoCard } from '../components/repos/RepoCard'
|
||||||
|
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { data: repos, isLoading } = useRepos()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-8">
|
||||||
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">Dashboard</h1>
|
<div>
|
||||||
<p className="text-[#5E6C84]">Coming soon — Phase 2 implementation.</p>
|
<h1 className="text-xl font-semibold text-[#172B4D]">Dashboard</h1>
|
||||||
|
<p className="text-sm text-[#5E6C84] mt-1">Your repositories and recent activity.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-[#172B4D] uppercase tracking-wide">
|
||||||
|
Your Repositories
|
||||||
|
</h2>
|
||||||
|
<Link to="/repos" className="text-xs text-[#0052CC] hover:underline">View all</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<RepoListSkeleton />
|
||||||
|
) : !repos?.length ? (
|
||||||
|
<EmptyRepos />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{repos.slice(0, 5).map(r => <RepoCard key={r.id} repo={r} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyRepos() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 border border-dashed border-[#DFE1E6] rounded text-center gap-3">
|
||||||
|
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[#172B4D]">No repositories yet</p>
|
||||||
|
<p className="text-xs text-[#5E6C84] mt-1">Create your first repository to get started.</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/repos"
|
||||||
|
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] min-h-[44px] flex items-center"
|
||||||
|
>
|
||||||
|
New repository
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { usePR } from '../api/queries/prs'
|
||||||
|
import { DiffViewer } from '../components/diff/DiffViewer'
|
||||||
|
import { MobileComment } from '../components/diff/MobileComment'
|
||||||
|
import { Skeleton } from '../ui/Skeleton'
|
||||||
|
import { cn } from '../lib/utils'
|
||||||
|
|
||||||
|
export default function PRDetailPage() {
|
||||||
|
const { owner = '', repo = '', prId = '' } = useParams<{ owner: string; repo: string; prId: string }>()
|
||||||
|
const { data: pr, isLoading, isError } = usePR(owner, repo, parseInt(prId, 10))
|
||||||
|
const [comment, setComment] = useState<{ file: string; line: number } | null>(null)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-6 space-y-4">
|
||||||
|
<Skeleton className="h-6 w-64" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !pr) {
|
||||||
|
return <div className="p-6 text-sm text-[#DE350B]">Pull request not found.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor = pr.status === 'open'
|
||||||
|
? 'bg-[#E3FCEF] text-[#006644] border-[#79F2C0]'
|
||||||
|
: pr.status === 'merged'
|
||||||
|
? 'bg-[#EAE6FF] text-[#403294] border-[#C0B6F2]'
|
||||||
|
: 'bg-[#F4F5F7] text-[#5E6C84] border-[#DFE1E6]'
|
||||||
|
|
||||||
|
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 flex-wrap">
|
||||||
|
<Link to={`/repos/${owner}/${repo}`} className="text-[#0052CC] hover:underline">{repo}</Link>
|
||||||
|
<span className="text-[#5E6C84]">/</span>
|
||||||
|
<Link to={`/repos/${owner}/${repo}/pulls`} className="text-[#0052CC] hover:underline">Pull requests</Link>
|
||||||
|
<span className="text-[#5E6C84]">/</span>
|
||||||
|
<span className="text-[#172B4D]">#{pr.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title + status */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h1 className="text-xl font-semibold text-[#172B4D]">{pr.title}</h1>
|
||||||
|
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', statusColor)}>
|
||||||
|
{pr.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#5E6C84] mt-1">
|
||||||
|
#{pr.id} · <span className="font-mono">{pr.sourceBranch}</span>
|
||||||
|
{' → '}
|
||||||
|
<span className="font-mono">{pr.targetBranch}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{pr.body && (
|
||||||
|
<div className="p-4 border border-[#DFE1E6] rounded text-sm text-[#172B4D] whitespace-pre-wrap">
|
||||||
|
{pr.body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Diff placeholder */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-[#172B4D] mb-3 flex items-center gap-2">
|
||||||
|
<svg width="16" height="16" fill="none" stroke="currentColor" 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>
|
||||||
|
Files changed
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Demo diff — real diff loads when repo has commits on both branches */}
|
||||||
|
<DiffViewer files={DEMO_DIFF} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile comment sheet */}
|
||||||
|
<MobileComment
|
||||||
|
open={!!comment}
|
||||||
|
onClose={() => setComment(null)}
|
||||||
|
filePath={comment?.file ?? ''}
|
||||||
|
lineNumber={comment?.line ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo diff to show the component before real git data is available
|
||||||
|
const DEMO_DIFF = [
|
||||||
|
{
|
||||||
|
path: 'README.md',
|
||||||
|
additions: 6,
|
||||||
|
deletions: 1,
|
||||||
|
patch: `@@ -1,3 +1,8 @@
|
||||||
|
-# Project
|
||||||
|
+# My Project
|
||||||
|
+
|
||||||
|
+A sovereign, federated git collaboration platform.
|
||||||
|
+
|
||||||
|
+## Features
|
||||||
|
+- Fast Go backend
|
||||||
|
+- React 18 frontend
|
||||||
|
+- ActivityPub federation
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,8 +1,38 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { cn } from '../lib/utils'
|
||||||
|
import type { PRStatus } from '../types/api'
|
||||||
|
|
||||||
|
// Global PR view — shows PRs across a known repo (MVP: uses first repo)
|
||||||
|
// In the full build this would aggregate across all user repos
|
||||||
export default function PRsPage() {
|
export default function PRsPage() {
|
||||||
|
const [status, setStatus] = useState<PRStatus>('open')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
|
||||||
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">PRs</h1>
|
<div className="flex items-center justify-between mb-6">
|
||||||
<p className="text-[#5E6C84]">Coming soon — Phase 2 implementation.</p>
|
<h1 className="text-xl font-semibold text-[#172B4D]">Pull Requests</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 mb-4 border-b border-[#DFE1E6]">
|
||||||
|
{(['open', 'merged', 'closed'] as PRStatus[]).map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setStatus(s)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-medium capitalize border-b-2 -mb-px transition-colors min-h-[44px]',
|
||||||
|
status === s
|
||||||
|
? 'border-[#0052CC] text-[#0052CC]'
|
||||||
|
: 'border-transparent text-[#5E6C84] hover:text-[#172B4D]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[#5E6C84] py-6 text-center">
|
||||||
|
Navigate to a repository to view its pull requests.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
|
import { PipelineWaterfall } from '../components/ci/PipelineWaterfall'
|
||||||
|
import type { Pipeline } from '../types/api'
|
||||||
|
|
||||||
|
const DEMO_PIPELINE: Pipeline = {
|
||||||
|
id: 1,
|
||||||
|
repoId: 1,
|
||||||
|
ref: 'main',
|
||||||
|
status: 'running',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
export default function PipelinesPage() {
|
export default function PipelinesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-6">
|
||||||
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">Pipelines</h1>
|
<h1 className="text-xl font-semibold text-[#172B4D]">Pipelines</h1>
|
||||||
<p className="text-[#5E6C84]">Coming soon — Phase 2 implementation.</p>
|
<p className="text-sm text-[#5E6C84]">CI/CD pipeline integration — preview below.</p>
|
||||||
|
<PipelineWaterfall pipeline={DEMO_PIPELINE} />
|
||||||
|
<PipelineWaterfall pipeline={{ ...DEMO_PIPELINE, id: 2, status: 'success' }} />
|
||||||
|
<PipelineWaterfall pipeline={{ ...DEMO_PIPELINE, id: 3, status: 'failure' }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { usePRs } from '../api/queries/prs'
|
||||||
|
import { PRListSkeleton } from '../ui/Skeleton'
|
||||||
|
import { cn } from '../lib/utils'
|
||||||
|
import type { PRStatus, PullRequest } from '../types/api'
|
||||||
|
|
||||||
|
export default function RepoPRsPage() {
|
||||||
|
const { owner = '', repo = '' } = useParams<{ owner: string; repo: string }>()
|
||||||
|
const [status, setStatus] = useState<PRStatus>('open')
|
||||||
|
const { data: prs, isLoading, isError } = usePRs(owner, repo)
|
||||||
|
|
||||||
|
const filtered = prs?.filter(p => p.status === status) ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
|
||||||
|
<div className="flex items-center gap-1 text-sm mb-4">
|
||||||
|
<Link to="/repos" className="text-[#0052CC] hover:underline">Repos</Link>
|
||||||
|
<span className="text-[#5E6C84]">/</span>
|
||||||
|
<Link to={`/repos/${owner}/${repo}`} className="text-[#0052CC] hover:underline">{repo}</Link>
|
||||||
|
<span className="text-[#5E6C84]">/</span>
|
||||||
|
<span className="font-semibold text-[#172B4D]">Pull requests</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h1 className="text-xl font-semibold text-[#172B4D]">Pull Requests</h1>
|
||||||
|
<Link
|
||||||
|
to={`/repos/${owner}/${repo}/pulls/new`}
|
||||||
|
className="px-3 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] min-h-[44px] flex items-center"
|
||||||
|
>
|
||||||
|
New PR
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 mb-4 border-b border-[#DFE1E6]">
|
||||||
|
{(['open', 'merged', 'closed'] as PRStatus[]).map(s => {
|
||||||
|
const count = prs?.filter(p => p.status === s).length ?? 0
|
||||||
|
return (
|
||||||
|
<button key={s} onClick={() => setStatus(s)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-medium capitalize border-b-2 -mb-px min-h-[44px]',
|
||||||
|
status === s ? 'border-[#0052CC] text-[#0052CC]' : 'border-transparent text-[#5E6C84] hover:text-[#172B4D]',
|
||||||
|
)}>
|
||||||
|
{s} {count > 0 && <span className="ml-1 text-xs">({count})</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <PRListSkeleton /> : isError ? (
|
||||||
|
<p className="text-sm text-[#DE350B]">Failed to load pull requests.</p>
|
||||||
|
) : !filtered.length ? (
|
||||||
|
<p className="text-sm text-[#5E6C84] py-8 text-center">No {status} pull requests.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{filtered.map(pr => <PRRow key={pr.id} pr={pr} owner={owner} repo={repo} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PRRow({ pr, owner, repo }: { pr: PullRequest; owner: string; repo: string }) {
|
||||||
|
const statusColor = pr.status === 'open'
|
||||||
|
? 'bg-[#E3FCEF] text-[#006644] border-[#79F2C0]'
|
||||||
|
: pr.status === 'merged'
|
||||||
|
? 'bg-[#EAE6FF] text-[#403294] border-[#C0B6F2]'
|
||||||
|
: 'bg-[#F4F5F7] text-[#5E6C84] border-[#DFE1E6]'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/repos/${owner}/${repo}/pulls/${pr.id}`}
|
||||||
|
className="flex items-start gap-3 p-4 border border-[#DFE1E6] rounded hover:border-[#4C9AFF] hover:bg-[#FAFBFC] transition-colors"
|
||||||
|
>
|
||||||
|
<span className={cn('text-[10px] font-semibold px-2 py-0.5 rounded-full border shrink-0 mt-0.5', statusColor)}>
|
||||||
|
{pr.status}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[#172B4D] truncate">{pr.title}</p>
|
||||||
|
<p className="text-xs text-[#5E6C84] mt-0.5">
|
||||||
|
#{pr.id} · {pr.sourceBranch} → {pr.targetBranch}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
||||||
|
import { useRepo } from '../api/queries/repos'
|
||||||
|
import { TreeBrowser } from '../components/repos/TreeBrowser'
|
||||||
|
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||||
|
|
||||||
|
export default function RepoPage() {
|
||||||
|
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const path = searchParams.get('path') ?? ''
|
||||||
|
const ref = searchParams.get('ref') ?? ''
|
||||||
|
|
||||||
|
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{repo.description && (
|
||||||
|
<p className="text-sm text-[#5E6C84]">{repo.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Branch + 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}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/repos/${owner}/${repoName}/pulls`}
|
||||||
|
className="text-sm text-[#0052CC] hover:underline"
|
||||||
|
>
|
||||||
|
Pull requests
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File tree */}
|
||||||
|
<TreeBrowser owner={owner} repo={repoName} ref={branch} path={path} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,103 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useRepos, useCreateRepo } from '../api/queries/repos'
|
||||||
|
import { RepoCard } from '../components/repos/RepoCard'
|
||||||
|
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||||
|
|
||||||
export default function ReposPage() {
|
export default function ReposPage() {
|
||||||
|
const { data: repos, isLoading, isError } = useRepos()
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
|
||||||
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">Repos</h1>
|
<div className="flex items-center justify-between mb-6">
|
||||||
<p className="text-[#5E6C84]">Coming soon — Phase 2 implementation.</p>
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-[#172B4D]">Repositories</h1>
|
||||||
|
{repos && (
|
||||||
|
<p className="text-sm text-[#5E6C84] mt-0.5">{repos.length} repositor{repos.length === 1 ? 'y' : 'ies'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] min-h-[44px]"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && <CreateRepoForm onClose={() => setShowCreate(false)} />}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<RepoListSkeleton />
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-sm text-[#DE350B]">Failed to load repositories.</p>
|
||||||
|
) : !repos?.length ? (
|
||||||
|
<p className="text-sm text-[#5E6C84] py-8 text-center">No repositories yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{repos.map(r => <RepoCard key={r.id} repo={r} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateRepoForm({ onClose }: { onClose: () => void }) {
|
||||||
|
const createRepo = useCreateRepo()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [isPrivate, setIsPrivate] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim()) return
|
||||||
|
await createRepo.mutateAsync({ name: name.trim(), description, isPrivate })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 p-5 border border-[#4C9AFF] rounded bg-white shadow-sm">
|
||||||
|
<h2 className="text-sm font-semibold text-[#172B4D] mb-4">New Repository</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[#172B4D] mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="my-project"
|
||||||
|
required
|
||||||
|
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[#172B4D] mb-1">Description</label>
|
||||||
|
<input
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={isPrivate} onChange={e => setIsPrivate(e.target.checked)} />
|
||||||
|
<span className="text-sm text-[#172B4D]">Private</span>
|
||||||
|
</label>
|
||||||
|
{createRepo.isError && (
|
||||||
|
<p className="text-xs text-[#DE350B]">{createRepo.error instanceof Error ? createRepo.error.message : 'Error'}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={createRepo.isPending || !name.trim()}
|
||||||
|
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] disabled:opacity-50 min-h-[44px]">
|
||||||
|
{createRepo.isPending ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded border border-[#DFE1E6] text-sm text-[#172B4D] hover:bg-[#F4F5F7] min-h-[44px]">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,31 @@ func (h *RepoHandler) Commits(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, commits)
|
jsonOK(w, commits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *RepoHandler) Diff(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, ok := h.lookupRepo(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
base := r.URL.Query().Get("base")
|
||||||
|
head := r.URL.Query().Get("head")
|
||||||
|
if base == "" || head == "" {
|
||||||
|
jsonError(w, "base and head query params are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gitdomain.SetRepoRoot(h.cfg.RepoRoot)
|
||||||
|
diffs, err := gitdomain.Diff(repo.DiskPath, base, head)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "could not compute diff", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if diffs == nil {
|
||||||
|
diffs = []gitdomain.FileDiff{}
|
||||||
|
}
|
||||||
|
jsonOK(w, diffs)
|
||||||
|
}
|
||||||
|
|
||||||
// lookupRepo resolves {owner}/{repo} URL params to a DB row, enforcing access.
|
// lookupRepo resolves {owner}/{repo} URL params to a DB row, enforcing access.
|
||||||
func (h *RepoHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
func (h *RepoHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
||||||
ownerName := chi.URLParam(r, "owner")
|
ownerName := chi.URLParam(r, "owner")
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
|
|||||||
r.Get("/tree", repoH.Tree)
|
r.Get("/tree", repoH.Tree)
|
||||||
r.Get("/blob", repoH.Blob)
|
r.Get("/blob", repoH.Blob)
|
||||||
r.Get("/commits", repoH.Commits)
|
r.Get("/commits", repoH.Commits)
|
||||||
|
r.Get("/diff", repoH.Diff)
|
||||||
r.Route("/pulls", func(r chi.Router) {
|
r.Route("/pulls", func(r chi.Router) {
|
||||||
r.Get("/", prH.List)
|
r.Get("/", prH.List)
|
||||||
r.With(csrf).Post("/", prH.Create)
|
r.With(csrf).Post("/", prH.Create)
|
||||||
|
|||||||
@@ -138,3 +138,67 @@ func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) {
|
|||||||
func BlobCat(repoPath, ref, filePath string) ([]byte, error) {
|
func BlobCat(repoPath, ref, filePath string) ([]byte, error) {
|
||||||
return run(repoPath, "show", ref+":"+filePath)
|
return run(repoPath, "show", ref+":"+filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileDiff represents a single changed file in a diff.
|
||||||
|
type FileDiff struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
OldPath string `json:"oldPath,omitempty"` // set on renames
|
||||||
|
Additions int `json:"additions"`
|
||||||
|
Deletions int `json:"deletions"`
|
||||||
|
Patch string `json:"patch"` // unified diff hunk(s) for this file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff returns per-file diffs between two refs.
|
||||||
|
func Diff(repoPath, base, head string) ([]FileDiff, error) {
|
||||||
|
out, err := run(repoPath,
|
||||||
|
"diff", "--unified=5", "--no-color",
|
||||||
|
"--diff-filter=ACDMRT", // exclude untracked
|
||||||
|
base+".."+head,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseUnifiedDiff(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUnifiedDiff splits a multi-file unified diff into per-file FileDiff entries.
|
||||||
|
func parseUnifiedDiff(raw string) []FileDiff {
|
||||||
|
var files []FileDiff
|
||||||
|
var cur *FileDiff
|
||||||
|
var patchLines []string
|
||||||
|
|
||||||
|
commit := func() {
|
||||||
|
if cur != nil {
|
||||||
|
cur.Patch = strings.Join(patchLines, "\n")
|
||||||
|
files = append(files, *cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
if strings.HasPrefix(line, "diff --git ") {
|
||||||
|
commit()
|
||||||
|
cur = &FileDiff{}
|
||||||
|
patchLines = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "--- a/"):
|
||||||
|
cur.OldPath = strings.TrimPrefix(line, "--- a/")
|
||||||
|
case strings.HasPrefix(line, "+++ b/"):
|
||||||
|
cur.Path = strings.TrimPrefix(line, "+++ b/")
|
||||||
|
case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
|
||||||
|
cur.Additions++
|
||||||
|
patchLines = append(patchLines, line)
|
||||||
|
case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
|
||||||
|
cur.Deletions++
|
||||||
|
patchLines = append(patchLines, line)
|
||||||
|
default:
|
||||||
|
patchLines = append(patchLines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
commit()
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user