Files
ForgeBucket/frontend/src/pages/PRDetailPage.tsx
T
erangel1 0310986644 Backend (prs.go):
Update — PATCH /{prID} edits title and/or body, validates title non-empty, returns prWithReviewers
Reopen — POST /{prID}/reopen transitions closed → open, fires webhook
Close now returns prWithReviewers and fires a webhook
Merge already existed; no changes needed
Frontend — PRDetailPage.tsx full rewrite:

Inline title editing — pencil icon (visible to author/admin when open), Enter to save, Esc to cancel
Inline body editing — same pattern in the description panel
Merge sidebar — radio buttons for allowed strategies (fetched from repo's merge strategy settings), "Merge pull request" button in Bitbucket purple, "Close without merging" below it
Status banner — merged (purple) or closed (grey) with the date, shown below the description
File list — scrollable +N −N table above the diff viewer showing all changed files with addition/deletion counts
Reopen button — appears in the sidebar when the PR is closed
Reviewers panel — lists assigned reviewers with avatars/initials
Details panel — from/into branches, opened date, last updated
Quick links — back to all PRs, open new PR
PRsPage.tsx — now shows real data:

Two tabs: "My pull requests" and "Awaiting my review" (with live counts from dashboard)
Per-repo quick links at the bottom showing open PR count badges
2026-05-07 17:07:16 +02:00

463 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { usePR, useMergePR, useClosePR, useReopenPR, useUpdatePR } from '../api/queries/prs'
import { useRepoDiff } from '../api/queries/repos'
import { useMergeStrategies } from '../api/queries/workflow'
import { useAuth } from '../contexts/AuthContext'
import { DiffViewer } from '../components/diff/DiffViewer'
import { Skeleton } from '../ui/Skeleton'
import { cn } from '../lib/utils'
import type { PRReviewer } from '../types/api'
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const min = Math.floor(diff / 60_000)
if (min < 1) return 'just now'
if (min < 60) return `${min}m ago`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h ago`
const d = Math.floor(hr / 24)
if (d < 30) return `${d}d ago`
return `${Math.floor(d / 30)}mo ago`
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
// ── Status badge ──────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
const styles = {
open: { bg: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]', dot: 'bg-[#00875A]', label: 'Open' },
merged: { bg: 'bg-[#EAE6FF] border-[#C0B6F2] text-[#403294]', dot: 'bg-[#6554C0]', label: 'Merged' },
closed: { bg: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]', dot: 'bg-[var(--c-muted)]', label: 'Closed' },
}[status] ?? { bg: '', dot: '', label: status }
return (
<span className={cn('inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full border', styles.bg)}>
<span className={cn('w-1.5 h-1.5 rounded-full', styles.dot)} />
{styles.label}
</span>
)
}
// ── Reviewer avatar ───────────────────────────────────────────────────────────
function ReviewerAvatar({ rv }: { rv: PRReviewer }) {
const color = `hsl(${(rv.username.charCodeAt(0) * 37) % 360},55%,45%)`
return (
<div className="flex items-center gap-2">
{rv.avatarUrl ? (
<img src={rv.avatarUrl} alt={rv.username} className="w-6 h-6 rounded-full object-cover shrink-0" />
) : (
<div className="w-6 h-6 rounded-full flex items-center justify-center text-white text-[10px] font-bold shrink-0" style={{ backgroundColor: color }}>
{rv.username[0].toUpperCase()}
</div>
)}
<span className="text-xs text-[var(--c-text)]">{rv.username}</span>
</div>
)
}
// ── Merge strategy selector ───────────────────────────────────────────────────
const STRATEGIES = [
{ value: 'merge', label: 'Merge commit', desc: 'Preserve all commits with a merge commit' },
{ value: 'squash', label: 'Squash and merge', desc: 'Combine all commits into one' },
{ value: 'rebase', label: 'Rebase and merge', desc: 'Rebase commits onto the target branch' },
] as const
export default function PRDetailPage() {
const { owner = '', repo = '', prId = '' } = useParams<{ owner: string; repo: string; prId: string }>()
const navigate = useNavigate()
const { user } = useAuth()
const numId = parseInt(prId, 10)
const { data: pr, isLoading, isError } = usePR(owner, repo, numId)
const { data: strategies } = useMergeStrategies(owner, repo)
const { data: diffs = [] } = useRepoDiff(owner, repo, pr?.targetBranch ?? '', pr?.sourceBranch ?? '')
const merge = useMergePR(owner, repo)
const close = useClosePR(owner, repo)
const reopen = useReopenPR(owner, repo)
const update = useUpdatePR(owner, repo, numId)
// Merge strategy
const [strategy, setStrategy] = useState<'merge' | 'squash' | 'rebase'>('merge')
const [mergeError, setMergeError] = useState('')
// Inline title editing
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
const [editingBody, setEditingBody] = useState(false)
const [bodyDraft, setBodyDraft] = useState('')
useEffect(() => {
if (pr) {
setTitleDraft(pr.title)
setBodyDraft(pr.body)
// Set default strategy to first allowed one
if (strategies) {
if (strategies.allowMergeCommit) setStrategy('merge')
else if (strategies.allowSquash) setStrategy('squash')
else if (strategies.allowRebase) setStrategy('rebase')
}
}
}, [pr, strategies])
if (isLoading) {
return (
<div className="max-w-5xl mx-auto px-4 py-6 space-y-4">
<Skeleton className="h-4 w-48 rounded" />
<Skeleton className="h-7 w-3/4 rounded" />
<Skeleton className="h-4 w-56 rounded" />
<div className="grid grid-cols-1 lg:grid-cols-[1fr_272px] gap-6 mt-6">
<div className="space-y-4">
<Skeleton className="h-28 rounded" />
<Skeleton className="h-64 rounded" />
</div>
<div className="space-y-3">
<Skeleton className="h-32 rounded" />
<Skeleton className="h-24 rounded" />
</div>
</div>
</div>
)
}
if (isError || !pr) {
return (
<div className="max-w-5xl mx-auto px-4 py-12 text-center">
<p className="text-4xl font-bold text-[var(--c-border)] mb-3">404</p>
<p className="text-sm text-[var(--c-muted)] mb-4">Pull request not found.</p>
<Link to={`/repos/${owner}/${repo}/pulls`} className="text-sm text-[var(--c-brand)] hover:underline">
Back to pull requests
</Link>
</div>
)
}
const isAuthor = user?.id === pr.authorId
const canEdit = isAuthor || user?.isAdmin
const isOpen = pr.status === 'open'
const isMerged = pr.status === 'merged'
const isClosed = pr.status === 'closed'
const allowedStrategies = STRATEGIES.filter(s => {
if (!strategies) return true
if (s.value === 'merge') return strategies.allowMergeCommit
if (s.value === 'squash') return strategies.allowSquash
if (s.value === 'rebase') return strategies.allowRebase
return false
})
async function handleMerge() {
setMergeError('')
try {
await merge.mutateAsync({ prId: numId, strategy })
navigate(`/repos/${owner}/${repo}/pulls`)
} catch (e: any) {
setMergeError(e.message ?? 'Merge failed.')
}
}
async function saveTitle() {
if (titleDraft.trim() === pr!.title) { setEditingTitle(false); return }
await update.mutateAsync({ title: titleDraft.trim() })
setEditingTitle(false)
}
async function saveBody() {
if (bodyDraft === pr!.body) { setEditingBody(false); return }
await update.mutateAsync({ body: bodyDraft })
setEditingBody(false)
}
const statLine = (
<p className="text-xs text-[var(--c-muted)] mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span>#{pr.id}</span>
<span>·</span>
<span className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">{pr.sourceBranch}</span>
<span className="text-[var(--c-subtle)]"></span>
<span className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">{pr.targetBranch}</span>
<span>·</span>
<span>opened {timeAgo(pr.createdAt)}</span>
{pr.updatedAt !== pr.createdAt && <><span>·</span><span>updated {timeAgo(pr.updatedAt)}</span></>}
</p>
)
return (
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-5">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm flex-wrap">
<Link to={`/repos/${owner}/${repo}`} className="text-[var(--c-brand)] hover:underline">{repo}</Link>
<span className="text-[var(--c-muted)]">/</span>
<Link to={`/repos/${owner}/${repo}/pulls`} className="text-[var(--c-brand)] hover:underline">Pull requests</Link>
<span className="text-[var(--c-muted)]">/</span>
<span className="text-[var(--c-text)]">#{pr.id}</span>
</div>
{/* Title row */}
<div>
{editingTitle ? (
<div className="flex items-center gap-2">
<input
value={titleDraft}
onChange={e => setTitleDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') saveTitle(); if (e.key === 'Escape') setEditingTitle(false) }}
className="flex-1 text-xl font-semibold border border-[var(--c-brand-focus)] rounded px-3 py-1.5 bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none"
autoFocus
/>
<button onClick={saveTitle} disabled={update.isPending}
className="px-3 py-1.5 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
Save
</button>
<button onClick={() => setEditingTitle(false)}
className="px-3 py-1.5 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
Cancel
</button>
</div>
) : (
<div className="flex items-start gap-3 flex-wrap">
<h1 className="text-xl font-semibold text-[var(--c-text)] flex-1 min-w-0">{pr.title}</h1>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={pr.status} />
{canEdit && isOpen && (
<button onClick={() => setEditingTitle(true)}
className="p-1.5 rounded text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors"
title="Edit title">
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
</svg>
</button>
)}
</div>
</div>
)}
{statLine}
</div>
{/* Main 2-col layout */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_272px] gap-6 items-start">
{/* ── Left: description + diff ── */}
<div className="space-y-5 min-w-0">
{/* Description */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Description</span>
{canEdit && isOpen && !editingBody && (
<button onClick={() => setEditingBody(true)}
className="flex items-center gap-1 text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] transition-colors">
<svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
</svg>
Edit
</button>
)}
</div>
{editingBody ? (
<div className="p-4 space-y-3">
<textarea value={bodyDraft} onChange={e => setBodyDraft(e.target.value)}
rows={8} className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm font-mono bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] resize-y" />
<div className="flex gap-2">
<button onClick={saveBody} disabled={update.isPending}
className="px-3 py-1.5 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
{update.isPending ? 'Saving…' : 'Save'}
</button>
<button onClick={() => { setBodyDraft(pr.body); setEditingBody(false) }}
className="px-3 py-1.5 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
Cancel
</button>
</div>
</div>
) : (
<div className="px-4 py-3">
{pr.body ? (
<p className="text-sm text-[var(--c-text)] whitespace-pre-wrap leading-relaxed">{pr.body}</p>
) : (
<p className="text-sm text-[var(--c-muted)] italic">No description provided.</p>
)}
</div>
)}
</div>
{/* Status banner for non-open PRs */}
{(isMerged || isClosed) && (
<div className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg border text-sm font-medium',
isMerged ? 'border-[#C0B6F2] bg-[#EAE6FF] text-[#403294]' : 'border-[var(--c-border)] bg-[var(--c-surface-muted)] text-[var(--c-muted)]',
)}>
{isMerged ? (
<>
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
Merged on {formatDate(pr.updatedAt)}
</>
) : (
<>
<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>
Closed on {formatDate(pr.updatedAt)} without merging
</>
)}
</div>
)}
{/* Files changed */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">
Files changed
{diffs.length > 0 && (
<span className="ml-1.5 text-[var(--c-text)]">({diffs.length})</span>
)}
</span>
<div className="flex items-center gap-2 text-xs">
{diffs.length > 0 && (
<>
<span className="text-[#006644]">+{diffs.reduce((s, f) => s + f.additions, 0)}</span>
<span className="text-[var(--c-danger)]">{diffs.reduce((s, f) => s + f.deletions, 0)}</span>
</>
)}
</div>
</div>
{diffs.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">
{pr.sourceBranch && pr.targetBranch
? 'No differences detected between branches.'
: 'Diff unavailable — branches may not exist locally.'}
</div>
) : (
<>
{/* File list */}
<ul className="border-b border-[var(--c-border)] divide-y divide-[var(--c-border)] max-h-40 overflow-y-auto">
{diffs.map(f => (
<li key={f.path} className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono hover:bg-[var(--c-surface-muted)]">
<span className="text-[#006644] tabular-nums w-8 text-right shrink-0">+{f.additions}</span>
<span className="text-[var(--c-danger)] tabular-nums w-8 text-right shrink-0">{f.deletions}</span>
<span className="text-[var(--c-text)] truncate">{f.path}</span>
</li>
))}
</ul>
<DiffViewer files={diffs} />
</>
)}
</div>
</div>
{/* ── Right sidebar ── */}
<div className="space-y-4">
{/* Merge / close / reopen actions */}
{isOpen && (
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
<div className="px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Merge pull request</span>
</div>
<div className="p-4 space-y-3">
{allowedStrategies.length > 0 ? (
<>
<div className="space-y-1.5">
{allowedStrategies.map(s => (
<label key={s.value} className="flex items-start gap-2.5 cursor-pointer group">
<input type="radio" name="strategy" value={s.value}
checked={strategy === s.value}
onChange={() => setStrategy(s.value)}
className="mt-0.5 accent-[var(--c-brand)]" />
<div>
<span className="text-sm font-medium text-[var(--c-text)] group-hover:text-[var(--c-brand)] transition-colors">{s.label}</span>
<p className="text-xs text-[var(--c-muted)]">{s.desc}</p>
</div>
</label>
))}
</div>
{mergeError && <p className="text-xs text-[var(--c-danger)]">{mergeError}</p>}
<button onClick={handleMerge} disabled={merge.isPending}
className="w-full py-2 rounded bg-[#6554C0] text-white text-sm font-medium hover:bg-[#5243AA] disabled:opacity-50 transition-colors">
{merge.isPending ? 'Merging…' : 'Merge pull request'}
</button>
</>
) : (
<p className="text-xs text-[var(--c-muted)]">No merge strategies are enabled for this repository.</p>
)}
<button onClick={() => close.mutate(numId)} disabled={close.isPending}
className="w-full py-1.5 rounded border border-[var(--c-border)] text-sm text-[var(--c-muted)] hover:text-[var(--c-danger)] hover:border-[var(--c-danger)] disabled:opacity-50 transition-colors">
{close.isPending ? 'Closing…' : 'Close without merging'}
</button>
</div>
</div>
)}
{isClosed && (
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
<div className="p-4">
<button onClick={() => reopen.mutate(numId)} disabled={reopen.isPending}
className="w-full py-2 rounded border border-[var(--c-brand)] text-[var(--c-brand)] text-sm font-medium hover:bg-[var(--c-brand-tint)] disabled:opacity-50 transition-colors">
{reopen.isPending ? 'Reopening…' : 'Reopen pull request'}
</button>
</div>
</div>
)}
{/* Reviewers */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
<div className="px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Reviewers</span>
</div>
<div className="p-3 space-y-2">
{pr.reviewers.length === 0 ? (
<p className="text-xs text-[var(--c-muted)]">No reviewers assigned.</p>
) : (
pr.reviewers.map(rv => <ReviewerAvatar key={rv.userId} rv={rv} />)
)}
</div>
</div>
{/* Metadata */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
<div className="px-4 py-2.5 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)]">
<span className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wider">Details</span>
</div>
<dl className="p-3 space-y-2.5 text-xs">
{[
{ label: 'From', value: <span className="font-mono text-[var(--c-text)]">{pr.sourceBranch}</span> },
{ label: 'Into', value: <span className="font-mono text-[var(--c-text)]">{pr.targetBranch}</span> },
{ label: 'Opened', value: <span className="text-[var(--c-text)]" title={pr.createdAt}>{formatDate(pr.createdAt)}</span> },
{ label: 'Updated', value: <span className="text-[var(--c-text)]" title={pr.updatedAt}>{timeAgo(pr.updatedAt)}</span> },
].map(row => (
<div key={row.label} className="flex items-center justify-between gap-2">
<dt className="text-[var(--c-muted)] shrink-0">{row.label}</dt>
<dd className="truncate">{row.value}</dd>
</div>
))}
</dl>
</div>
{/* Quick links */}
<div className="flex flex-col gap-1">
<Link to={`/repos/${owner}/${repo}/pulls`}
className="flex items-center gap-2 text-xs text-[var(--c-muted)] hover:text-[var(--c-brand)] transition-colors px-1 py-0.5">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
All pull requests
</Link>
<Link to={`/repos/${owner}/${repo}/pulls/new`}
className="flex items-center gap-2 text-xs text-[var(--c-muted)] hover:text-[var(--c-brand)] transition-colors px-1 py-0.5">
<svg width="12" height="12" 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 pull request
</Link>
</div>
</div>
</div>
</div>
)
}