diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d760654..1ec087d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,8 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { AppShell } from './components/layout/AppShell' import { RepoListSkeleton } from './ui/Skeleton' -import { Suspense, lazy } from 'react' +import { Suspense, lazy, useEffect } from 'react' +import { bootstrapCSRF } from './api/client' import './index.css' const queryClient = new QueryClient({ @@ -15,36 +16,50 @@ const queryClient = new QueryClient({ }, }) -const DashboardPage = lazy(() => import('./pages/DashboardPage')) -const ReposPage = lazy(() => import('./pages/ReposPage')) -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')) +// Pages — code-split per route +const DashboardPage = lazy(() => import('./pages/DashboardPage')) +const ReposPage = lazy(() => import('./pages/ReposPage')) +const RepoPage = lazy(() => import('./pages/RepoPage')) +const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage')) +const PRDetailPage = lazy(() => import('./pages/PRDetailPage')) +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() { - return ( -
- -
- ) + return
+} + +function S({ children }: { children: React.ReactNode }) { + return }>{children} +} + +// Primes the CSRF cookie once when the SPA mounts +function CSRFBootstrap() { + useEffect(() => { bootstrapCSRF() }, []) + return null } export default function App() { return ( + }> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 73e6800..a99f4b7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,12 +2,20 @@ import { z } from 'zod' let csrfToken: string | null = null +// Called once on app bootstrap. Fetches the CSRF token and sets the cookie. +export async function bootstrapCSRF(): Promise { + 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 { if (csrfToken) return csrfToken - const res = await fetch('/api/v1/csrf', { credentials: 'include' }) - if (!res.ok) throw new Error('Failed to fetch CSRF token') - csrfToken = res.headers.get('X-CSRF-Token') ?? '' - return csrfToken + await bootstrapCSRF() + return csrfToken ?? '' } export class ApiError extends Error { diff --git a/frontend/src/api/queries/repos.ts b/frontend/src/api/queries/repos.ts index 7f8521f..fe0c9ed 100644 --- a/frontend/src/api/queries/repos.ts +++ b/frontend/src/api/queries/repos.ts @@ -3,6 +3,16 @@ import { z } from 'zod' import { api } from '../client' 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({ id: 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() { const queryClient = useQueryClient() return useMutation({ diff --git a/frontend/src/components/ci/PipelineWaterfall.tsx b/frontend/src/components/ci/PipelineWaterfall.tsx new file mode 100644 index 0000000..a262164 --- /dev/null +++ b/frontend/src/components/ci/PipelineWaterfall.tsx @@ -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 = { + 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 = { + pending: 'bg-[#97A0AF]', + running: 'bg-[#0052CC] animate-pulse', + success: 'bg-[#00875A]', + failure: 'bg-[#DE350B]', + cancelled: 'bg-[#97A0AF]', +} + +const STATUS_LABEL: Record = { + 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 ( +
+ {/* Pipeline header */} +
+
+ + + Pipeline #{pipeline.id} + + + {STATUS_LABEL[pipeline.status]} + +
+ {pipeline.ref} +
+ + {/* Waterfall stages */} +
+ {resolvedStages.map((stage, i) => ( +
+ {/* Stage box */} +
+ + {stage.name} + {stage.duration && ( + {stage.duration} + )} +
+ + {/* Connector arrow (not after last) */} + {i < resolvedStages.length - 1 && ( +
+
+ + + +
+ )} +
+ ))} +
+
+ ) +} + +function StatusIcon({ status }: { status: Pipeline['status'] }) { + if (status === 'success') { + return ( + + + + ) + } + if (status === 'failure') { + return ( + + + + ) + } + if (status === 'running') { + return ( + + + + ) + } + return ( + + + + ) +} diff --git a/frontend/src/components/diff/DiffViewer.tsx b/frontend/src/components/diff/DiffViewer.tsx new file mode 100644 index 0000000..80d8105 --- /dev/null +++ b/frontend/src/components/diff/DiffViewer.tsx @@ -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 ( +
+ No changes in this diff. +
+ ) + } + + return ( +
+ {files.map(file => ( + + ))} +
+ ) +} + +function FileDiffBlock({ file }: { file: FileDiff }) { + const [collapsed, setCollapsed] = useState(false) + const lines = useMemo(() => parsePatch(file.patch), [file.patch]) + + return ( +
+ {/* File header */} +
+ +
+ {file.additions > 0 && ( + +{file.additions} + )} + {file.deletions > 0 && ( + -{file.deletions} + )} +
+
+ + {/* Diff lines */} + {!collapsed && ( +
+ + + {lines.map((line, i) => ( + + ))} + +
+
+ )} +
+ ) +} + +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 ( + + + + {line.content} + + ) + } + + 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 ( + + + {line.type !== 'added' ? line.oldNo : ''} + + + {line.type !== 'removed' ? line.newNo : ''} + + + {prefix} + {line.content} + + + ) +} diff --git a/frontend/src/components/diff/MobileComment.tsx b/frontend/src/components/diff/MobileComment.tsx new file mode 100644 index 0000000..73edbe7 --- /dev/null +++ b/frontend/src/components/diff/MobileComment.tsx @@ -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(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 */} +
+ + {/* Bottom sheet */} +
+ {/* Handle */} +
+
+
+ + {/* Header */} +
+
+

{filePath}

+

Line {lineNumber}

+
+ +
+ + {/* Content */} +
+ {children ?? ( +