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
This commit is contained in:
2026-05-07 17:07:16 +02:00
parent 7436679eac
commit 0310986644
11 changed files with 896 additions and 78 deletions
+178
View File
@@ -0,0 +1,178 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useRepo, useRepoBranches } from '../api/queries/repos'
import { useCreatePR } from '../api/queries/prs'
import { useDefaultDescription } from '../api/queries/prs'
import { Skeleton } from '../ui/Skeleton'
export default function CreatePRPage() {
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
const navigate = useNavigate()
const { data: repoData, isLoading: repoLoading } = useRepo(owner, repoName)
const { data: branches = [], isLoading: branchesLoading } = useRepoBranches(owner, repoName)
const { data: descData } = useDefaultDescription(owner, repoName)
const createPR = useCreatePR(owner, repoName)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [sourceBranch, setSourceBranch] = useState('')
const [targetBranch, setTargetBranch] = useState('')
const [error, setError] = useState('')
// Pre-fill target branch from repo default and body from template
useEffect(() => {
if (repoData && !targetBranch) setTargetBranch(repoData.defaultBranch)
}, [repoData])
useEffect(() => {
if (descData?.template && !body) setBody(descData.template)
}, [descData])
const isLoading = repoLoading || branchesLoading
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!title.trim()) { setError('Title is required.'); return }
if (!sourceBranch) { setError('Source branch is required.'); return }
if (!targetBranch) { setError('Target branch is required.'); return }
if (sourceBranch === targetBranch) { setError('Source and target branch must be different.'); return }
try {
const pr = await createPR.mutateAsync({
title: title.trim(),
body,
sourceBranch,
targetBranch,
})
navigate(`/repos/${owner}/${repoName}/pulls/${pr.id}`)
} catch (err: any) {
setError(err.message ?? 'Failed to create pull request.')
}
}
if (isLoading) {
return (
<div className="max-w-2xl mx-auto px-4 md:px-6 py-6 space-y-4">
<Skeleton className="h-5 w-48 rounded" />
<Skeleton className="h-10 rounded" />
<Skeleton className="h-10 rounded" />
<Skeleton className="h-32 rounded" />
</div>
)
}
const branchOptions = branches.map(b => b.name)
const isEmpty = repoData?.isEmpty ?? false
return (
<div className="max-w-2xl 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/${owner}/${repoName}`} className="text-[var(--c-brand)] hover:underline">{repoName}</Link>
<span className="text-[var(--c-muted)]">/</span>
<Link to={`/repos/${owner}/${repoName}/pulls`} className="text-[var(--c-brand)] hover:underline">Pull requests</Link>
<span className="text-[var(--c-muted)]">/</span>
<span className="font-semibold text-[var(--c-text)]">New</span>
</div>
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Open a pull request</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">Propose changes by selecting branches to merge.</p>
</div>
{isEmpty ? (
<div className="rounded-lg border border-[var(--c-border)] p-8 text-center space-y-2">
<p className="text-sm font-medium text-[var(--c-text)]">Repository is empty</p>
<p className="text-xs text-[var(--c-muted)]">Push at least one commit before opening a pull request.</p>
</div>
) : branchOptions.length < 2 ? (
<div className="rounded-lg border border-[var(--c-border)] p-8 text-center space-y-2">
<p className="text-sm font-medium text-[var(--c-text)]">Not enough branches</p>
<p className="text-xs text-[var(--c-muted)]">You need at least two branches to open a pull request.</p>
<Link to={`/repos/${owner}/${repoName}/branches`} className="text-xs text-[var(--c-brand)] hover:underline">
View branches
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Branch selectors */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex-1 min-w-[160px]">
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Source branch</label>
<select value={sourceBranch} onChange={e => setSourceBranch(e.target.value)} required
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]">
<option value="">Select branch</option>
{branchOptions.filter(b => b !== targetBranch).map(b => (
<option key={b} value={b}>{b}</option>
))}
</select>
</div>
<div className="flex items-center pt-5 text-[var(--c-muted)]">
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</div>
<div className="flex-1 min-w-[160px]">
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Target branch</label>
<select value={targetBranch} onChange={e => setTargetBranch(e.target.value)} required
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]">
<option value="">Select branch</option>
{branchOptions.filter(b => b !== sourceBranch).map(b => (
<option key={b} value={b}>{b}</option>
))}
</select>
</div>
</div>
{/* Title */}
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">
Title <span className="text-[var(--c-danger)]">*</span>
</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Brief description of the change"
maxLength={255}
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]"
/>
</div>
{/* Body */}
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Description</label>
<textarea
value={body}
onChange={e => setBody(e.target.value)}
rows={10}
placeholder="Describe what changed and why…"
className="w-full border border-[var(--c-border)] rounded px-3 py-2.5 text-sm font-mono bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] resize-y"
/>
</div>
{error && (
<p className="text-sm text-[var(--c-danger)] bg-[var(--c-danger)]/5 border border-[var(--c-danger)]/20 rounded px-3 py-2">
{error}
</p>
)}
<div className="flex items-center gap-3">
<button type="submit" disabled={createPR.isPending}
className="px-5 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
{createPR.isPending ? 'Opening…' : 'Open pull request'}
</button>
<Link to={`/repos/${owner}/${repoName}/pulls`}
className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
Cancel
</Link>
</div>
</form>
)}
</div>
)
}
+423 -56
View File
@@ -1,44 +1,194 @@
import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { usePR } from '../api/queries/prs'
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 { MobileComment } from '../components/diff/MobileComment'
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 { data: pr, isLoading, isError } = usePR(owner, repo, parseInt(prId, 10))
const { data: diffs } = useRepoDiff(
owner, repo,
pr?.targetBranch ?? '',
pr?.sourceBranch ?? '',
)
const [comment, setComment] = useState<{ file: string; line: number } | null>(null)
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-6 w-64" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-40 w-full" />
<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="p-6 text-sm text-[var(--c-danger)]">Pull request not found.</div>
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 statusColor = pr.status === 'open'
? 'bg-[#E3FCEF] text-[#006644] border-[#79F2C0]'
: pr.status === 'merged'
? 'bg-[#EAE6FF] text-[#403294] border-[#C0B6F2]'
: 'bg-[var(--c-surface-muted)] text-[var(--c-muted)] border-[var(--c-border)]'
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-6">
<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>
@@ -48,48 +198,265 @@ export default function PRDetailPage() {
<span className="text-[var(--c-text)]">#{pr.id}</span>
</div>
{/* Title + status */}
{/* Title row */}
<div>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl font-semibold text-[var(--c-text)]">{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-[var(--c-muted)] mt-1">
#{pr.id} · <span className="font-mono">{pr.sourceBranch}</span>
{' → '}
<span className="font-mono">{pr.targetBranch}</span>
</p>
{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>
{/* Body */}
{pr.body && (
<div className="p-4 border border-[var(--c-border)] rounded text-sm text-[var(--c-text)] whitespace-pre-wrap">
{pr.body}
{/* 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>
)}
{/* Diff placeholder */}
<div>
<h2 className="text-sm font-semibold text-[var(--c-text)] 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>
{/* ── Right sidebar ── */}
<div className="space-y-4">
<DiffViewer files={diffs ?? []} />
{/* 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>
{/* Mobile comment sheet */}
<MobileComment
open={!!comment}
onClose={() => setComment(null)}
filePath={comment?.file ?? ''}
lineNumber={comment?.line ?? 0}
/>
</div>
)
}
+143 -18
View File
@@ -1,38 +1,163 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useDashboard } from '../api/queries/dashboard'
import { Skeleton } from '../ui/Skeleton'
import { cn } from '../lib/utils'
import type { PRStatus } from '../types/api'
import type { DashPR } from '../api/queries/dashboard'
type Tab = 'review' | 'mine'
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 PRRow({ pr, badge }: { pr: DashPR; badge?: React.ReactNode }) {
return (
<Link
to={`/repos/${pr.ownerName}/${pr.repoName}/pulls/${pr.id}`}
className="flex items-start gap-3 p-4 border-b border-[var(--c-border)] last:border-b-0 hover:bg-[var(--c-surface-muted)] transition-colors group"
>
<svg className="mt-0.5 shrink-0 text-[#00875A]" width="15" height="15" 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>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--c-text)] group-hover:text-[var(--c-brand)] transition-colors truncate">{pr.title}</span>
{badge}
</div>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
<Link to={`/repos/${pr.ownerName}/${pr.repoName}/pulls`} onClick={e => e.stopPropagation()}
className="font-mono hover:underline">{pr.ownerName}/{pr.repoName}</Link>
{' · '}
<span className="font-mono">{pr.sourceBranch}</span>
{' → '}
<span className="font-mono">{pr.targetBranch}</span>
{' · '}
{timeAgo(pr.updatedAt)}
</p>
</div>
<svg className="shrink-0 text-[var(--c-border)] group-hover:text-[var(--c-brand)] transition-colors mt-0.5" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</Link>
)
}
function EmptyState({ tab }: { tab: Tab }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center gap-3">
<svg width="36" height="36" fill="none" stroke="var(--c-border)" strokeWidth="1.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>
<div>
<p className="text-sm font-medium text-[var(--c-text)]">
{tab === 'review' ? 'No pull requests awaiting your review' : 'No open pull requests'}
</p>
<p className="text-xs text-[var(--c-muted)] mt-1">
{tab === 'review' ? "You're all caught up." : 'Open a PR from any repository to get started.'}
</p>
</div>
</div>
)
}
// 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() {
const [status, setStatus] = useState<PRStatus>('open')
const [tab, setTab] = useState<Tab>('mine')
const { data: dash, isLoading } = useDashboard()
const tabs: { id: Tab; label: string; count?: number }[] = [
{ id: 'mine', label: 'My pull requests', count: dash?.myOpenPRs.length },
{ id: 'review', label: 'Awaiting my review', count: dash?.reviewQueue.length },
]
const rows = tab === 'mine' ? (dash?.myOpenPRs ?? []) : (dash?.reviewQueue ?? [])
return (
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
<div className="flex items-center justify-between mb-6">
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-[var(--c-text)]">Pull Requests</h1>
<Link to="/repos" className="text-xs text-[var(--c-brand)] hover:underline">
Browse repos
</Link>
</div>
<div className="flex gap-1 mb-4 border-b border-[var(--c-border)]">
{(['open', 'merged', 'closed'] as PRStatus[]).map(s => (
<button
key={s}
onClick={() => setStatus(s)}
{/* Tabs */}
<div className="flex gap-0 border-b border-[var(--c-border)]">
{tabs.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={cn(
'px-4 py-2 text-sm font-medium capitalize border-b-2 -mb-px transition-colors min-h-[44px]',
status === s
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
tab === t.id
? 'border-[var(--c-brand)] text-[var(--c-brand)]'
: 'border-transparent text-[var(--c-muted)] hover:text-[var(--c-text)]',
)}>
{t.label}
{t.count !== undefined && t.count > 0 && (
<span className={cn(
'text-[10px] font-bold px-1.5 py-0.5 rounded-full',
tab === t.id ? 'bg-[var(--c-brand)] text-white' : 'bg-[var(--c-surface-muted)] text-[var(--c-muted)]',
)}>
{t.count}
</span>
)}
>
{s}
</button>
))}
</div>
<p className="text-sm text-[var(--c-muted)] py-6 text-center">
Navigate to a repository to view its pull requests.
</p>
{/* List */}
{isLoading ? (
<div className="border border-[var(--c-border)] rounded-lg divide-y divide-[var(--c-border)]">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 p-4">
<Skeleton className="w-4 h-4 rounded-full mt-0.5 shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4 rounded" />
<Skeleton className="h-3 w-1/2 rounded" />
</div>
</div>
))}
</div>
) : rows.length === 0 ? (
<EmptyState tab={tab} />
) : (
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
{rows.map(pr => (
<PRRow key={`${pr.repoId}-${pr.id}`} pr={pr} badge={
tab === 'review' ? (
<span className="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-px rounded-full bg-[var(--c-warning)]/15 text-[var(--c-warning)] border border-[var(--c-warning)]/30 shrink-0">
Review requested
</span>
) : undefined
} />
))}
</div>
)}
{/* Per-repo links */}
{!isLoading && dash && dash.repos.length > 0 && (
<div className="pt-2">
<p className="text-xs text-[var(--c-muted)] mb-2">Browse PRs by repository:</p>
<div className="flex flex-wrap gap-2">
{dash.repos.slice(0, 8).map(r => (
<Link key={r.id} to={`/repos/${r.ownerName}/${r.name}/pulls`}
className="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-[var(--c-border)] rounded-md bg-[var(--c-surface)] text-[var(--c-text)] hover:border-[var(--c-brand-focus)] hover:text-[var(--c-brand)] transition-colors">
{r.name}
{r.openPrCount > 0 && (
<span className="text-[10px] font-bold text-white bg-[var(--c-brand)] rounded-full px-1.5 py-px">{r.openPrCount}</span>
)}
</Link>
))}
</div>
</div>
)}
</div>
)
}