Files
ForgeBucket/frontend/src/pages/RepoSettingsPage.tsx
T
erangel1 803672a610 Git LFS section is live with:
Enable LFS toggle — turns LFS on/off for the repo; all other controls dim when disabled
File locking toggle — enables the LFS locking protocol for binary assets
Maximum file size — optional per-file size cap in MB (blank = unlimited)
Info callout linking to the git-lfs client install page and noting the .gitattributes requirement
2026-05-07 16:12:25 +02:00

1839 lines
94 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useRepo, useUpdateRepo, useDeleteRepo, useUploadRepoAvatar } from '../api/queries/repos'
import { useRepoMembers, useAddMember, useUpdateMember, useRemoveMember } from '../api/queries/members'
import { useDeployKeys, useCreateDeployKey, useDeleteDeployKey, useAccessTokens, useCreateAccessToken, useDeleteAccessToken } from '../api/queries/keys'
import {
useBranchProtections, useCreateBranchProtection, useUpdateBranchProtection, useDeleteBranchProtection,
useBranchingModel, useUpdateBranchingModel,
useMergeStrategies, useUpdateMergeStrategies,
useWebhooks, useCreateWebhook, useUpdateWebhook, useDeleteWebhook, useTestWebhook,
} from '../api/queries/workflow'
import {
useDefaultReviewers, useAddDefaultReviewer, useRemoveDefaultReviewer,
useDefaultDescription, useUpdateDefaultDescription,
useExcludedFiles, useUpdateExcludedFiles,
} from '../api/queries/prs'
import { useLFSSettings, useUpdateLFSSettings } from '../api/queries/lfs'
import { useRecentRepos } from '../hooks/useRecentRepos'
import { useAuth } from '../contexts/AuthContext'
import { Skeleton } from '../ui/Skeleton'
import { RepoAvatar } from '../ui/RepoAvatar'
// ─── Sidebar config ───────────────────────────────────────────────────────────
type SectionId =
| 'repository-details'
| 'repository-permissions'
| 'access-keys'
| 'access-tokens'
| 'branch-restrictions'
| 'branching-model'
| 'merge-strategies'
| 'webhooks'
| 'default-reviewers'
| 'default-description'
| 'excluded-files'
| 'git-lfs'
const SIDEBAR: { group: string; items: { id: SectionId; label: string }[] }[] = [
{
group: 'General',
items: [
{ id: 'repository-details', label: 'Repository details' },
{ id: 'repository-permissions', label: 'Repository permissions' },
],
},
{
group: 'Security',
items: [
{ id: 'access-keys', label: 'Access keys' },
{ id: 'access-tokens', label: 'Access tokens' },
],
},
{
group: 'Workflow',
items: [
{ id: 'branch-restrictions', label: 'Branch restrictions' },
{ id: 'branching-model', label: 'Branching model' },
{ id: 'merge-strategies', label: 'Merge strategies' },
{ id: 'webhooks', label: 'Webhooks' },
],
},
{
group: 'Pull Requests',
items: [
{ id: 'default-reviewers', label: 'Default reviewers' },
{ id: 'default-description', label: 'Default description' },
{ id: 'excluded-files', label: 'Excluded files' },
],
},
{
group: 'Features',
items: [
{ id: 'git-lfs', label: 'Git LFS' },
],
},
]
const SECTION_META: Record<SectionId, { title: string; description: string }> = {
'repository-details': { title: 'Repository details', description: '' },
'repository-permissions': { title: 'Repository permissions', description: 'Control who can read, write, or administer this repository. Invite collaborators and manage team access.' },
'access-keys': { title: 'Access keys', description: 'SSH deploy keys give read or write access to this repository without requiring an interactive user account. Useful for CI/CD.' },
'access-tokens': { title: 'Access tokens', description: 'Repository access tokens give third-party tools secure, scoped access to this repository via the API.' },
'branch-restrictions': { title: 'Branch restrictions', description: 'Protect important branches by restricting who can push, delete, or force-push to them.' },
'branching-model': { title: 'Branching model', description: 'Define a branching strategy (e.g. Gitflow) to enforce consistent branch naming across your team.' },
'merge-strategies': { title: 'Merge strategies', description: 'Configure which merge strategies (merge commit, squash, rebase) are allowed for pull requests.' },
'webhooks': { title: 'Webhooks', description: 'Send real-time HTTP POST notifications to external services when events occur in this repository.' },
'default-reviewers': { title: 'Default reviewers', description: 'Automatically add reviewers to pull requests based on changed files or the target branch.' },
'default-description': { title: 'Default description', description: 'Set a template that pre-fills the pull request body when a new PR is created.' },
'excluded-files': { title: 'Excluded files', description: 'Specify files that should be excluded from pull request diff views and review requirements.' },
'git-lfs': { title: 'Git LFS', description: 'Git Large File Storage replaces large files with text pointers inside git, while storing the actual files on a remote server.' },
}
// ─── Page shell ───────────────────────────────────────────────────────────────
export default function RepoSettingsPage() {
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const [sidebarSearch, setSidebarSearch] = useState('')
const section = (searchParams.get('section') ?? 'repository-details') as SectionId
const filtered = sidebarSearch.trim()
? SIDEBAR.map(g => ({ ...g, items: g.items.filter(i => i.label.toLowerCase().includes(sidebarSearch.toLowerCase())) })).filter(g => g.items.length > 0)
: SIDEBAR
return (
<div className="flex h-full overflow-hidden">
{/* Sidebar */}
<aside className="w-60 shrink-0 border-r border-[var(--c-border)] bg-[var(--c-surface)] hidden md:flex flex-col">
<div className="p-3 border-b border-[var(--c-border)] space-y-2.5 shrink-0">
<Link to={`/repos/${owner}/${repoName}`} className="flex items-center gap-1.5 text-sm text-[var(--c-text)] hover:text-[var(--c-brand)] font-medium">
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
Repository settings
</Link>
<div className="relative">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--c-muted)] pointer-events-none" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input value={sidebarSearch} onChange={e => setSidebarSearch(e.target.value)} placeholder="Jump to settings…" className="w-full text-xs bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded pl-7 pr-3 py-1.5 focus:outline-none focus:border-[var(--c-brand-focus)]" />
</div>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{filtered.map(group => (
<div key={group.group} className="mb-1">
<p className="text-[10px] font-semibold text-[var(--c-muted)] uppercase tracking-wider px-4 py-1.5">{group.group}</p>
{group.items.map(item => (
<button key={item.id} onClick={() => setSearchParams({ section: item.id })}
className={`w-full text-left px-4 py-2 text-sm transition-colors border-l-[3px] ${section === item.id ? 'bg-[var(--c-brand-tint)] text-[var(--c-brand)] font-medium border-[var(--c-brand)]' : 'text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] border-transparent'}`}>
{item.label}
</button>
))}
</div>
))}
</nav>
</aside>
{/* Content */}
<main className="flex-1 overflow-y-auto min-w-0">
<div className="md:hidden p-3 border-b border-[var(--c-border)] bg-[var(--c-surface)]">
<select value={section} onChange={e => setSearchParams({ section: e.target.value })} className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none">
{SIDEBAR.map(g => g.items.map(i => <option key={i.id} value={i.id}>{g.group} {i.label}</option>))}
</select>
</div>
{section === 'repository-details' && <RepositoryDetailsSection owner={owner} repo={repoName} />}
{section === 'repository-permissions' && <RepositoryPermissionsSection owner={owner} repo={repoName} />}
{section === 'access-keys' && <AccessKeysSection owner={owner} repo={repoName} />}
{section === 'access-tokens' && <AccessTokensSection owner={owner} repo={repoName} />}
{section === 'branch-restrictions' && <BranchRestrictionsSection owner={owner} repo={repoName} />}
{section === 'branching-model' && <BranchingModelSection owner={owner} repo={repoName} />}
{section === 'merge-strategies' && <MergeStrategiesSection owner={owner} repo={repoName} />}
{section === 'webhooks' && <WebhooksSection owner={owner} repo={repoName} />}
{section === 'default-reviewers' && <DefaultReviewersSection owner={owner} repo={repoName} />}
{section === 'default-description' && <DefaultDescriptionSection owner={owner} repo={repoName} />}
{section === 'excluded-files' && <ExcludedFilesSection owner={owner} repo={repoName} />}
{section === 'git-lfs' && <GitLFSSection owner={owner} repo={repoName} />}
{!['repository-details','repository-permissions','access-keys','access-tokens',
'branch-restrictions','branching-model','merge-strategies','webhooks',
'default-reviewers','default-description','excluded-files','git-lfs'].includes(section) && <ComingSoon sectionId={section} />}
</main>
</div>
)
}
// ─── Repository details ───────────────────────────────────────────────────────
function formatSize(bytes: number): string {
if (!bytes) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
return `${(bytes / 1024 ** 3).toFixed(2)} GB`
}
function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string }) {
const navigate = useNavigate()
const { data: repoData, isLoading } = useRepo(owner, repo)
const updateRepo = useUpdateRepo(owner, repo)
const deleteRepo = useDeleteRepo(owner, repo)
const uploadAvatar = useUploadRepoAvatar(owner, repo)
const { remove: removeRecent } = useRecentRepos()
// Form state
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [isPrivate, setIsPrivate] = useState(false)
const [defaultBranch, setDefaultBranch] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [showManage, setShowManage] = useState(false)
const [confirmDelete, setConfirmDelete] = useState('')
const [saved, setSaved] = useState(false)
const [nameError, setNameError] = useState('')
// Avatar state
const fileInputRef = useRef<HTMLInputElement>(null)
const [avatarPreview, setAvatarPreview] = useState<string | null>(null)
const manageRef = useRef<HTMLDivElement>(null)
// Initialize form from API data
useEffect(() => {
if (repoData) {
setName(repoData.name)
setDescription(repoData.description ?? '')
setIsPrivate(repoData.isPrivate)
setDefaultBranch(repoData.defaultBranch)
}
}, [repoData])
// Close manage dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (manageRef.current && !manageRef.current.contains(e.target as Node)) setShowManage(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const isDirty = repoData != null && (
name !== repoData.name ||
description !== (repoData.description ?? '') ||
isPrivate !== repoData.isPrivate ||
defaultBranch !== repoData.defaultBranch
)
function handleDiscard() {
if (!repoData) return
setName(repoData.name)
setDescription(repoData.description ?? '')
setIsPrivate(repoData.isPrivate)
setDefaultBranch(repoData.defaultBranch)
setNameError('')
}
function handleNameChange(val: string) {
setName(val)
if (val && !/^[a-zA-Z0-9._-]+$/.test(val)) {
setNameError('Only letters, numbers, hyphens, underscores, and dots are allowed.')
} else {
setNameError('')
}
}
async function handleSave(e: React.FormEvent) {
e.preventDefault()
if (nameError) return
const payload: Record<string, unknown> = { description, isPrivate, defaultBranch }
if (name !== repoData?.name) payload.name = name
const updated = await updateRepo.mutateAsync(payload)
if (updated.name !== repo) {
// Renamed — navigate to new URL
navigate(`/repos/${owner}/${updated.name}/settings?section=repository-details`, { replace: true })
} else {
setSaved(true)
setTimeout(() => setSaved(false), 3000)
}
}
async function handleDelete() {
if (confirmDelete !== repo) return
await deleteRepo.mutateAsync()
removeRecent(owner, repo)
navigate('/repos')
}
const handleAvatarFile = useCallback((file: File) => {
const reader = new FileReader()
reader.onload = () => setAvatarPreview(reader.result as string)
reader.readAsDataURL(file)
uploadAvatar.mutate(file)
}, [uploadAvatar])
if (isLoading || !repoData) {
return (
<div className="max-w-2xl px-6 py-6 space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
{/* Page header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-1 text-xs text-[var(--c-muted)] mb-1.5">
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{owner}</Link>
<span>/</span>
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{repo}</Link>
</div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Repository details</h1>
</div>
{/* Manage repository dropdown */}
<div className="relative shrink-0" ref={manageRef}>
<button
onClick={() => setShowManage(s => !s)}
className="flex items-center gap-1.5 px-3 py-1.5 border border-[var(--c-border)] rounded text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] font-medium"
>
Manage repository
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
{showManage && (
<div className="absolute right-0 top-full mt-1 w-52 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-lg shadow-xl z-20 overflow-hidden">
<button
onClick={() => { setShowManage(false); setShowAdvanced(true); setTimeout(() => document.getElementById('danger-zone')?.scrollIntoView({ behavior: 'smooth' }), 50) }}
className="w-full flex items-center gap-2 px-4 py-3 text-sm text-[var(--c-danger)] hover:bg-[var(--c-danger-tint)] text-left"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
Delete repository
</button>
</div>
)}
</div>
</div>
<div className="border-t border-[var(--c-border)]" />
<form onSubmit={handleSave} className="space-y-6">
{/* Avatar */}
<div>
<label className="block text-sm font-medium text-[var(--c-text)] mb-3">Avatar</label>
<div className="flex items-center gap-4">
{/* Clickable avatar */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="rounded-lg overflow-hidden ring-2 ring-[var(--c-border)] hover:ring-[var(--c-brand-focus)] transition-all focus:outline-none"
title="Click to change avatar"
>
{avatarPreview ? (
<img src={avatarPreview} alt="Preview" className="w-14 h-14 object-cover" />
) : (
<RepoAvatar
ownerName={owner}
name={repoData.name}
avatarUrl={repoData.avatarUrl}
size={56}
/>
)}
</button>
<div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadAvatar.isPending}
className="px-3 py-1.5 text-sm border border-[var(--c-border)] rounded text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] disabled:opacity-50"
>
{uploadAvatar.isPending ? 'Uploading…' : 'Change avatar'}
</button>
<p className="text-xs text-[var(--c-muted)] mt-1">JPEG, PNG, GIF or WebP · max 5 MB</p>
{uploadAvatar.isError && (
<p className="text-xs text-[var(--c-danger)] mt-1">{(uploadAvatar.error as Error).message}</p>
)}
{uploadAvatar.isSuccess && !avatarPreview && (
<p className="text-xs text-[var(--c-success)] mt-1">Avatar updated.</p>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleAvatarFile(f) }}
/>
</div>
</div>
{/* Repository name */}
<div>
<label className="block text-sm font-medium text-[var(--c-text)] mb-1">
Repository name <span className="text-[var(--c-danger)]">*</span>
</label>
<input
value={name}
onChange={e => handleNameChange(e.target.value)}
className={`w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 ${nameError ? 'border-[var(--c-danger)] focus:border-[var(--c-danger)] focus:ring-[var(--c-danger)]' : 'border-[var(--c-border)] focus:border-[var(--c-brand-focus)] focus:ring-[var(--c-brand-focus)]'}`}
/>
{nameError
? <p className="text-xs text-[var(--c-danger)] mt-1">{nameError}</p>
: name !== repoData.name
? <p className="text-xs text-[var(--c-warning)] mt-1 flex items-center gap-1">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>
Renaming will change the clone URL all existing git remotes will need to be updated.
</p>
: <p className="text-xs text-[var(--c-muted)] mt-1">Letters, numbers, hyphens, underscores, and dots only.</p>
}
</div>
{/* Size (read-only) */}
<div>
<label className="block text-sm font-medium text-[var(--c-text)] mb-1">Size</label>
<p className="text-sm text-[var(--c-text)]">{formatSize(repoData.size)}</p>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-[var(--c-text)] mb-1">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={4}
placeholder="Describe this repository…"
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)] resize-y"
/>
</div>
{/* Access level */}
<div>
<label className="block text-sm font-medium text-[var(--c-text)] mb-2">Access level</label>
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={isPrivate}
onChange={e => setIsPrivate(e.target.checked)}
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]"
/>
<div>
<span className="text-sm font-medium text-[var(--c-text)]">
{isPrivate ? 'This is a private repository' : 'This is a public repository'}
</span>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
{isPrivate
? 'Only collaborators you invite can see and push to this repository.'
: 'Anyone can view this repository. Make it private to restrict access.'}
</p>
</div>
</label>
</div>
{/* Advanced (collapsible) */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowAdvanced(s => !s)}
className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] text-left"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24"
className={`transition-transform shrink-0 ${showAdvanced ? 'rotate-90' : ''}`}>
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
Advanced
</button>
{showAdvanced && (
<div className="border-t border-[var(--c-border)] px-4 py-5 space-y-6 bg-[var(--c-surface-raised)]">
{/* Default branch */}
<div>
<label className="block text-sm font-medium text-[var(--c-text)] mb-1">Default branch</label>
<input
value={defaultBranch}
onChange={e => setDefaultBranch(e.target.value)}
placeholder="main"
className="w-full max-w-xs bg-[var(--c-surface)] border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--c-brand-focus)]"
/>
<p className="text-xs text-[var(--c-muted)] mt-1">The default branch used as the base for new pull requests.</p>
</div>
{/* Danger zone */}
<div id="danger-zone" className="border border-[var(--c-danger-tint)] rounded-lg overflow-hidden">
<div className="px-4 py-3 bg-[var(--c-danger-tint)]/60 border-b border-[var(--c-danger-tint)]">
<h3 className="text-sm font-semibold text-[var(--c-danger-dark)]">Delete repository</h3>
</div>
<div className="px-4 py-4 space-y-3 bg-[var(--c-surface)]">
<p className="text-sm text-[var(--c-text)]">
This is permanent and cannot be undone. All commits, branches, pull requests, issues, and settings will be permanently deleted.
</p>
<p className="text-xs text-[var(--c-muted)]">
Type <code className="font-mono bg-[var(--c-surface-muted)] border border-[var(--c-border)] px-1.5 py-0.5 rounded text-[var(--c-text)]">{repo}</code> to confirm.
</p>
<input
value={confirmDelete}
onChange={e => setConfirmDelete(e.target.value)}
placeholder={repo}
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--c-danger)]"
/>
<button
type="button"
onClick={handleDelete}
disabled={confirmDelete !== repo || deleteRepo.isPending}
className="px-4 py-2 rounded bg-[var(--c-danger)] text-white text-sm font-medium hover:bg-[var(--c-danger-dark)] disabled:opacity-40"
>
{deleteRepo.isPending ? 'Deleting…' : 'Delete repository'}
</button>
{deleteRepo.isError && (
<p className="text-xs text-[var(--c-danger)]">{(deleteRepo.error as Error).message}</p>
)}
</div>
</div>
</div>
)}
</div>
{/* Footer: error / saved / Discard / Save */}
<div className="flex items-center justify-end gap-3 pt-2 border-t border-[var(--c-border)]">
{updateRepo.isError && (
<p className="text-xs text-[var(--c-danger)] mr-auto">{(updateRepo.error as Error).message}</p>
)}
{saved && !updateRepo.isError && (
<span className="text-xs text-[var(--c-success)] font-medium mr-auto flex items-center gap-1">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
Changes saved
</span>
)}
<button type="button" onClick={handleDiscard} disabled={!isDirty}
className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] disabled:opacity-40 min-h-[36px]">
Discard
</button>
<button type="submit" disabled={updateRepo.isPending || !isDirty || !!nameError}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50 min-h-[36px]">
{updateRepo.isPending ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
</div>
)
}
// ─── Repository permissions section ──────────────────────────────────────────
const PERMISSION_LABELS: Record<string, { label: string; description: string; color: string }> = {
read: { label: 'Read', description: 'Can clone and pull', color: 'bg-[var(--c-surface-muted)] text-[var(--c-muted)]' },
write: { label: 'Write', description: 'Can push branches and commits', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' },
admin: { label: 'Admin', description: 'Can manage settings and members', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300' },
}
function PermissionBadge({ permission }: { permission: string }) {
const p = PERMISSION_LABELS[permission] ?? PERMISSION_LABELS.read
return (
<span className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full ${p.color}`}>
{p.label}
</span>
)
}
function RepositoryPermissionsSection({ owner, repo }: { owner: string; repo: string }) {
const { user } = useAuth()
const { data: members, isLoading } = useRepoMembers(owner, repo)
const addMember = useAddMember(owner, repo)
const updateMember = useUpdateMember(owner, repo)
const removeMember = useRemoveMember(owner, repo)
const [username, setUsername] = useState('')
const [permission, setPermission] = useState('write')
const [addError, setAddError] = useState('')
const isOwner = members?.find(m => m.isOwner)?.username === user?.username
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
setAddError('')
if (!username.trim()) return
try {
await addMember.mutateAsync({ username: username.trim(), permission })
setUsername('')
} catch (err) {
setAddError((err as Error).message)
}
}
async function handlePermissionChange(memberUsername: string, newPermission: string) {
await updateMember.mutateAsync({ username: memberUsername, permission: newPermission })
}
async function handleRemove(memberUsername: string) {
await removeMember.mutateAsync(memberUsername)
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Repository permissions</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Manage who has access to this repository and what they can do.
</p>
</div>
<div className="border-t border-[var(--c-border)]" />
{/* Permission level reference */}
<div className="grid grid-cols-3 gap-3">
{Object.entries(PERMISSION_LABELS).map(([key, val]) => (
<div key={key} className="border border-[var(--c-border)] rounded-lg p-3 bg-[var(--c-surface)]">
<PermissionBadge permission={key} />
<p className="text-xs text-[var(--c-muted)] mt-2">{val.description}</p>
</div>
))}
</div>
{/* Member list */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Members</h2>
<span className="text-xs text-[var(--c-muted)]">{members?.length ?? 0} {members?.length === 1 ? 'person' : 'people'}</span>
</div>
{isLoading ? (
<div className="p-4 space-y-3">
{[1,2].map(i => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="w-8 h-8 rounded-full" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-16 ml-auto" />
</div>
))}
</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{members?.map(member => (
<li key={member.userId} className="flex items-center gap-3 px-4 py-3">
{/* Avatar */}
<div className="w-8 h-8 rounded-full overflow-hidden shrink-0 bg-[var(--c-brand)] flex items-center justify-center text-white text-sm font-bold">
{member.avatarUrl
? <img src={member.avatarUrl} alt={member.username} className="w-full h-full object-cover" />
: member.username[0]?.toUpperCase()
}
</div>
{/* Name */}
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-[var(--c-text)]">{member.username}</span>
{member.isOwner && (
<span className="ml-2 text-[10px] text-[var(--c-muted)]">owner</span>
)}
</div>
{/* Permission selector or badge */}
{member.isOwner ? (
<PermissionBadge permission="admin" />
) : isOwner ? (
<div className="flex items-center gap-2">
<select
value={member.permission}
onChange={e => handlePermissionChange(member.username, e.target.value)}
disabled={updateMember.isPending}
className="text-xs border border-[var(--c-border)] rounded px-2 py-1 bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]"
>
<option value="read">Read</option>
<option value="write">Write</option>
<option value="admin">Admin</option>
</select>
<button
onClick={() => handleRemove(member.username)}
disabled={removeMember.isPending}
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] disabled:opacity-40 p-1 rounded hover:bg-[var(--c-danger-tint)] transition-colors"
title="Remove member"
>
<svg width="14" height="14" 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>
) : (
<PermissionBadge permission={member.permission} />
)}
</li>
))}
</ul>
)}
</div>
{/* Add member form — only for owner/admin */}
{isOwner && (
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Add a member</h2>
</div>
<form onSubmit={handleAdd} className="p-4 space-y-4">
<div className="flex gap-3 flex-wrap">
<div className="flex-1 min-w-[180px]">
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Username</label>
<input
value={username}
onChange={e => { setUsername(e.target.value); setAddError('') }}
placeholder="e.g. alice"
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)] focus:ring-1 focus:ring-[var(--c-brand-focus)]"
/>
</div>
<div className="w-36">
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Permission</label>
<select
value={permission}
onChange={e => setPermission(e.target.value)}
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="read">Read</option>
<option value="write">Write</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div className="text-xs text-[var(--c-muted)]">
{PERMISSION_LABELS[permission]?.description}
</div>
{addError && (
<p className="text-xs text-[var(--c-danger)]">{addError}</p>
)}
<button
type="submit"
disabled={addMember.isPending || !username.trim()}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
>
{addMember.isPending ? 'Adding…' : 'Add member'}
</button>
</form>
</div>
)}
{/* Info for non-owners */}
{!isOwner && !isLoading && (
<div className="flex items-start gap-3 p-4 border border-[var(--c-border)] rounded-lg bg-[var(--c-surface-raised)]">
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
<p className="text-sm text-[var(--c-muted)]">
Only the repository owner and admins can manage member permissions.
</p>
</div>
)}
</div>
)
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
function TokenReveal({ token, onDismiss }: { token: string; onDismiss: () => void }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(token)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="border border-[var(--c-success)] rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-[var(--c-surface-raised)] border-b border-[var(--c-success)]/30">
<svg width="14" height="14" fill="none" stroke="var(--c-success)" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span className="text-sm font-semibold text-[var(--c-success)]">Token created copy it now, it won't be shown again</span>
</div>
<div className="p-4 space-y-3 bg-[var(--c-surface)]">
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-xs bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2 text-[var(--c-text)] break-all">
{token}
</code>
<button onClick={copy}
className="shrink-0 px-3 py-2 rounded border border-[var(--c-border)] text-xs font-medium text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors">
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<button onClick={onDismiss} className="text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] underline">
I've copied it, dismiss
</button>
</div>
</div>
)
}
// ─── Access keys (deploy keys) section ───────────────────────────────────────
function AccessKeysSection({ owner, repo }: { owner: string; repo: string }) {
const { data: keys, isLoading } = useDeployKeys(owner, repo)
const createKey = useCreateDeployKey(owner, repo)
const deleteKey = useDeleteDeployKey(owner, repo)
const [title, setTitle] = useState('')
const [readOnly, setReadOnly] = useState(true)
const [newToken, setNewToken] = useState<string | null>(null)
const [formError, setFormError] = useState('')
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setFormError('')
if (!title.trim()) return
try {
const key = await createKey.mutateAsync({ title: title.trim(), readOnly })
if (key.token) setNewToken(key.token)
setTitle('')
setReadOnly(true)
} catch (err) {
setFormError((err as Error).message)
}
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Access keys</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Deploy keys grant git clone and push access to this repository over HTTP. Each key is shown only once.
</p>
<div className="mt-3 p-3 rounded-lg bg-[var(--c-surface-raised)] border border-[var(--c-border)] text-xs text-[var(--c-muted)]">
Use as git credentials: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">git clone http://x-deploy-key:&lt;TOKEN&gt;@{window.location.hostname}/{owner}/{repo}.git</code>
</div>
</div>
<div className="border-t border-[var(--c-border)]" />
{/* Token reveal banner */}
{newToken && <TokenReveal token={newToken} onDismiss={() => setNewToken(null)} />}
{/* Existing keys */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Active keys</h2>
<span className="text-xs text-[var(--c-muted)]">{keys?.length ?? 0} key{keys?.length !== 1 ? 's' : ''}</span>
</div>
{isLoading ? (
<div className="p-4 space-y-3">
{[1, 2].map(i => <div key={i} className="flex gap-3"><Skeleton className="h-4 w-32" /><Skeleton className="h-4 w-16 ml-auto" /></div>)}
</div>
) : !keys?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No deploy keys yet.</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{keys.map(key => (
<li key={key.id} className="flex items-center gap-3 px-4 py-3">
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 0 1 21.75 8.25Z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--c-text)]">{key.title}</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
{key.readOnly ? 'Read-only' : 'Read & write'} · Added {new Date(key.createdAt).toLocaleDateString()}
</p>
</div>
<span className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full ${key.readOnly ? 'bg-[var(--c-surface-muted)] text-[var(--c-muted)]' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'}`}>
{key.readOnly ? 'Read' : 'Write'}
</span>
<button
onClick={() => deleteKey.mutate(key.id)}
disabled={deleteKey.isPending}
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] transition-colors disabled:opacity-40"
title="Revoke key"
>
<svg width="14" height="14" 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>
</li>
))}
</ul>
)}
</div>
{/* Create form */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Add a deploy key</h2>
</div>
<form onSubmit={handleCreate} className="p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Label</label>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. CI/CD pipeline"
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)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" />
</div>
<label className="flex items-start gap-2.5 cursor-pointer">
<input type="checkbox" checked={readOnly} onChange={e => setReadOnly(e.target.checked)}
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]" />
<div>
<span className="text-sm font-medium text-[var(--c-text)]">Read-only</span>
<p className="text-xs text-[var(--c-muted)] mt-0.5">Uncheck to allow pushes with this key.</p>
</div>
</label>
{formError && <p className="text-xs text-[var(--c-danger)]">{formError}</p>}
<button type="submit" disabled={createKey.isPending || !title.trim()}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
{createKey.isPending ? 'Generating…' : 'Generate key'}
</button>
</form>
</div>
</div>
)
}
// ─── Access tokens section ────────────────────────────────────────────────────
function AccessTokensSection({ owner, repo }: { owner: string; repo: string }) {
const { data: tokens, isLoading } = useAccessTokens(owner, repo)
const createToken = useCreateAccessToken(owner, repo)
const deleteToken = useDeleteAccessToken(owner, repo)
const [title, setTitle] = useState('')
const [scopes, setScopes] = useState<string[]>(['read'])
const [expiresAt, setExpiresAt] = useState('')
const [newToken, setNewToken] = useState<string | null>(null)
const [formError, setFormError] = useState('')
function toggleScope(scope: string) {
setScopes(prev =>
prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]
)
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setFormError('')
if (!title.trim() || scopes.length === 0) return
try {
const tok = await createToken.mutateAsync({
title: title.trim(),
scopes: scopes.join(','),
expiresAt: expiresAt || undefined,
})
if (tok.token) setNewToken(tok.token)
setTitle('')
setScopes(['read'])
setExpiresAt('')
} catch (err) {
setFormError((err as Error).message)
}
}
const scopeLabels: Record<string, { label: string; description: string }> = {
read: { label: 'Read', description: 'Read repo contents, branches, commits, PRs, issues' },
write: { label: 'Write', description: 'Push code, create PRs and issues' },
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Access tokens</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Tokens grant programmatic access to this repository's API and git operations. Each token is shown only once.
</p>
<div className="mt-3 p-3 rounded-lg bg-[var(--c-surface-raised)] border border-[var(--c-border)] text-xs text-[var(--c-muted)] space-y-1">
<p>API: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">Authorization: Bearer &lt;TOKEN&gt;</code></p>
<p>Git: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">git clone http://x-token:&lt;TOKEN&gt;@{window.location.hostname}/{owner}/{repo}.git</code></p>
</div>
</div>
<div className="border-t border-[var(--c-border)]" />
{newToken && <TokenReveal token={newToken} onDismiss={() => setNewToken(null)} />}
{/* Token list */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Active tokens</h2>
<span className="text-xs text-[var(--c-muted)]">{tokens?.length ?? 0} token{tokens?.length !== 1 ? 's' : ''}</span>
</div>
{isLoading ? (
<div className="p-4 space-y-3">
{[1, 2].map(i => <div key={i} className="flex gap-3"><Skeleton className="h-4 w-40" /><Skeleton className="h-4 w-20 ml-auto" /></div>)}
</div>
) : !tokens?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No access tokens yet.</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{tokens.map(tok => {
const expired = tok.expiresAt && new Date(tok.expiresAt) < new Date()
return (
<li key={tok.id} className="flex items-center gap-3 px-4 py-3">
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--c-text)]">{tok.title}</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Scopes: {tok.scopes}
{tok.expiresAt && (
<span className={expired ? ' · Expired' : ` · Expires ${new Date(tok.expiresAt).toLocaleDateString()}`} />
)}
{!tok.expiresAt && ' · No expiry'}
</p>
</div>
{expired && (
<span className="text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-[var(--c-danger-tint)] text-[var(--c-danger)]">Expired</span>
)}
<button
onClick={() => deleteToken.mutate(tok.id)}
disabled={deleteToken.isPending}
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] transition-colors disabled:opacity-40"
title="Revoke token"
>
<svg width="14" height="14" 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>
</li>
)
})}
</ul>
)}
</div>
{/* Create form */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Create an access token</h2>
</div>
<form onSubmit={handleCreate} className="p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Token name</label>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. GitHub Actions"
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)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" />
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-2">Scopes</label>
<div className="space-y-2">
{Object.entries(scopeLabels).map(([key, val]) => (
<label key={key} className="flex items-start gap-2.5 cursor-pointer">
<input type="checkbox" checked={scopes.includes(key)} onChange={() => toggleScope(key)}
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]" />
<div>
<span className="text-sm font-medium text-[var(--c-text)]">{val.label}</span>
<p className="text-xs text-[var(--c-muted)]">{val.description}</p>
</div>
</label>
))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Expiry date <span className="text-[var(--c-subtle)]">(optional)</span></label>
<input type="date" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="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>
{formError && <p className="text-xs text-[var(--c-danger)]">{formError}</p>}
<button type="submit" disabled={createToken.isPending || !title.trim() || scopes.length === 0}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
{createToken.isPending ? 'Generating' : 'Generate token'}
</button>
</form>
</div>
</div>
)
}
// ─── Branch restrictions ──────────────────────────────────────────────────────
function BranchRestrictionsSection({ owner, repo }: { owner: string; repo: string }) {
const { data: rules, isLoading } = useBranchProtections(owner, repo)
const createRule = useCreateBranchProtection(owner, repo)
const updateRule = useUpdateBranchProtection(owner, repo)
const deleteRule = useDeleteBranchProtection(owner, repo)
const [pattern, setPattern] = useState('')
const [requirePR, setRequirePR] = useState(true)
const [blockForcePush, setBlockForcePush] = useState(true)
const [allowedUsers, setAllowedUsers] = useState('')
const [error, setError] = useState('')
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setError('')
try {
await createRule.mutateAsync({ pattern: pattern.trim(), requirePR, blockForcePush, allowedUsers: allowedUsers.trim() })
setPattern('')
} catch (err) { setError((err as Error).message) }
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Branch restrictions</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Protect branches from direct pushes. Matched branches can only be updated via pull requests.
</p>
</div>
<div className="border-t border-[var(--c-border)]" />
{/* Rule list */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex justify-between">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Protected branches</h2>
<span className="text-xs text-[var(--c-muted)]">{rules?.length ?? 0} rule{rules?.length !== 1 ? 's' : ''}</span>
</div>
{isLoading ? (
<div className="p-4"><Skeleton className="h-4 w-full" /></div>
) : !rules?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No branch protection rules yet.</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{rules.map(rule => (
<li key={rule.id} className="px-4 py-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<svg width="14" height="14" fill="none" stroke="var(--c-brand)" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
<code className="text-sm font-mono font-semibold text-[var(--c-text)]">{rule.pattern}</code>
</div>
<button onClick={() => deleteRule.mutate(rule.id)} disabled={deleteRule.isPending}
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] disabled:opacity-40">
<svg width="14" height="14" 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>
<div className="flex gap-4 text-xs text-[var(--c-muted)] ml-6">
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={rule.requirePR} className="w-3 h-3 accent-[var(--c-brand)]"
onChange={e => updateRule.mutate({ id: rule.id, requirePR: e.target.checked, blockForcePush: rule.blockForcePush, allowedUsers: rule.allowedUsers })} />
Require PR
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={rule.blockForcePush} className="w-3 h-3 accent-[var(--c-brand)]"
onChange={e => updateRule.mutate({ id: rule.id, requirePR: rule.requirePR, blockForcePush: e.target.checked, allowedUsers: rule.allowedUsers })} />
Block force push
</label>
{rule.allowedUsers && <span>Bypass: <code className="text-[var(--c-text)]">{rule.allowedUsers}</code></span>}
</div>
</li>
))}
</ul>
)}
</div>
{/* Create rule form */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Add a rule</h2>
</div>
<form onSubmit={handleCreate} className="p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Branch pattern</label>
<input value={pattern} onChange={e => setPattern(e.target.value)} placeholder="main, release/*, v*"
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] font-mono focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" />
<p className="text-xs text-[var(--c-muted)] mt-1">Glob patterns supported: <code className="font-mono">release/*</code> matches all release branches.</p>
</div>
<div className="flex gap-6">
<label className="flex items-center gap-2 cursor-pointer text-sm text-[var(--c-text)]">
<input type="checkbox" checked={requirePR} onChange={e => setRequirePR(e.target.checked)} className="w-4 h-4 accent-[var(--c-brand)]" />
Require pull request
</label>
<label className="flex items-center gap-2 cursor-pointer text-sm text-[var(--c-text)]">
<input type="checkbox" checked={blockForcePush} onChange={e => setBlockForcePush(e.target.checked)} className="w-4 h-4 accent-[var(--c-brand)]" />
Block force push
</label>
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Bypass list <span className="text-[var(--c-subtle)]">(optional — comma-separated usernames)</span></label>
<input value={allowedUsers} onChange={e => setAllowedUsers(e.target.value)} placeholder="alice, bob"
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>
{error && <p className="text-xs text-[var(--c-danger)]">{error}</p>}
<button type="submit" disabled={createRule.isPending || !pattern.trim()}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
{createRule.isPending ? 'Adding' : 'Add rule'}
</button>
</form>
</div>
</div>
)
}
// ─── Branching model ──────────────────────────────────────────────────────────
function BranchingModelSection({ owner, repo }: { owner: string; repo: string }) {
const { data, isLoading } = useBranchingModel(owner, repo)
const updateModel = useUpdateBranchingModel(owner, repo)
const [form, setForm] = useState({ enabled: false, featurePrefix: 'feature/', bugfixPrefix: 'bugfix/', releasePrefix: 'release/', hotfixPrefix: 'hotfix/' })
const [saved, setSaved] = useState(false)
useEffect(() => { if (data) setForm(data) }, [data])
async function handleSave(e: React.FormEvent) {
e.preventDefault()
await updateModel.mutateAsync(form)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
}
const branches = [
{ key: 'featurePrefix' as const, label: 'Feature', example: 'feature/my-feature', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' },
{ key: 'bugfixPrefix' as const, label: 'Bugfix', example: 'bugfix/fix-login', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' },
{ key: 'releasePrefix' as const, label: 'Release', example: 'release/1.0.0', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
{ key: 'hotfixPrefix' as const, label: 'Hotfix', example: 'hotfix/critical-fix', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300' },
]
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Branching model</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">Define naming conventions for branch types to keep your team consistent.</p>
</div>
<div className="border-t border-[var(--c-border)]" />
{isLoading ? <Skeleton className="h-48 w-full" /> : (
<form onSubmit={handleSave} className="space-y-5">
<label className="flex items-center gap-3 p-4 border border-[var(--c-border)] rounded-lg cursor-pointer bg-[var(--c-surface)] hover:bg-[var(--c-surface-raised)]">
<input type="checkbox" checked={form.enabled} onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))}
className="w-4 h-4 accent-[var(--c-brand)]" />
<div>
<span className="text-sm font-medium text-[var(--c-text)]">Enable branching model</span>
<p className="text-xs text-[var(--c-muted)] mt-0.5">Apply the naming conventions below to this repository.</p>
</div>
</label>
<div className={`space-y-3 transition-opacity ${form.enabled ? '' : 'opacity-40 pointer-events-none'}`}>
{branches.map(b => (
<div key={b.key} className="flex items-center gap-3 p-3 border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)]">
<span className={`text-[10px] font-bold uppercase tracking-wide px-2 py-1 rounded w-20 text-center shrink-0 ${b.color}`}>{b.label}</span>
<input value={form[b.key]} onChange={e => setForm(f => ({ ...f, [b.key]: e.target.value }))}
className="flex-1 border border-[var(--c-border)] rounded px-3 py-1.5 text-sm font-mono bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
<span className="text-xs text-[var(--c-muted)] font-mono hidden sm:block">{form[b.key] || ''}my-branch</span>
</div>
))}
</div>
<div className="flex items-center gap-3 pt-2 border-t border-[var(--c-border)]">
{saved && <span className="text-xs text-[var(--c-success)] font-medium">Saved!</span>}
<button type="submit" disabled={updateModel.isPending}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50 ml-auto">
{updateModel.isPending ? 'Saving' : 'Save changes'}
</button>
</div>
</form>
)}
</div>
)
}
// ─── Merge strategies ─────────────────────────────────────────────────────────
function MergeStrategiesSection({ owner, repo }: { owner: string; repo: string }) {
const { data, isLoading } = useMergeStrategies(owner, repo)
const updateStrategies = useUpdateMergeStrategies(owner, repo)
const [form, setForm] = useState({ allowMergeCommit: true, allowSquash: true, allowRebase: true })
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
useEffect(() => { if (data) setForm(data) }, [data])
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setError('')
try {
await updateStrategies.mutateAsync(form)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
} catch (err) { setError((err as Error).message) }
}
const strategies = [
{ key: 'allowMergeCommit' as const, label: 'Merge commit', description: 'Combines all commits from the branch into a merge commit on the target branch.', icon: 'M' },
{ key: 'allowSquash' as const, label: 'Squash', description: 'Squashes all branch commits into a single commit on the target branch.', icon: 'S' },
{ key: 'allowRebase' as const, label: 'Rebase', description: 'Replays each branch commit on top of the target branch without a merge commit.', icon: 'R' },
]
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Merge strategies</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">Control which merge strategies collaborators can use when closing pull requests.</p>
</div>
<div className="border-t border-[var(--c-border)]" />
{isLoading ? <Skeleton className="h-48 w-full" /> : (
<form onSubmit={handleSave} className="space-y-4">
{strategies.map(s => (
<label key={s.key} className="flex items-start gap-4 p-4 border border-[var(--c-border)] rounded-lg cursor-pointer hover:bg-[var(--c-surface-raised)] bg-[var(--c-surface)]">
<input type="checkbox" checked={form[s.key]} onChange={e => setForm(f => ({ ...f, [s.key]: e.target.checked }))}
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded bg-[var(--c-brand-tint)] text-[var(--c-brand)] text-xs font-bold flex items-center justify-center">{s.icon}</span>
<span className="text-sm font-medium text-[var(--c-text)]">{s.label}</span>
</div>
<p className="text-xs text-[var(--c-muted)] mt-1">{s.description}</p>
</div>
</label>
))}
{error && <p className="text-xs text-[var(--c-danger)]">{error}</p>}
<div className="flex items-center gap-3 pt-2 border-t border-[var(--c-border)]">
{saved && <span className="text-xs text-[var(--c-success)] font-medium">Saved!</span>}
<button type="submit" disabled={updateStrategies.isPending}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50 ml-auto">
{updateStrategies.isPending ? 'Saving' : 'Save changes'}
</button>
</div>
</form>
)}
</div>
)
}
// ─── Webhooks ─────────────────────────────────────────────────────────────────
const ALL_EVENTS = ['push', 'pull_request', 'issue']
function WebhooksSection({ owner, repo }: { owner: string; repo: string }) {
const { data: hooks, isLoading } = useWebhooks(owner, repo)
const createHook = useCreateWebhook(owner, repo)
const updateHook = useUpdateWebhook(owner, repo)
const deleteHook = useDeleteWebhook(owner, repo)
const testHook = useTestWebhook(owner, repo)
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState<number | null>(null)
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [secret, setSecret] = useState('')
const [events, setEvents] = useState<string[]>(['push'])
const [active, setActive] = useState(true)
const [formError, setFormError] = useState('')
const [testResult, setTestResult] = useState<Record<number, { ok: boolean; status: number }>>({})
function resetForm() {
setTitle(''); setUrl(''); setSecret(''); setEvents(['push']); setActive(true)
setFormError(''); setEditId(null); setShowForm(false)
}
function startEdit(wh: { id: number; title: string; url: string; events: string; active: boolean }) {
setEditId(wh.id); setTitle(wh.title); setUrl(wh.url)
setEvents(wh.events.split(',')); setActive(wh.active); setSecret('')
setShowForm(true)
}
function toggleEvent(ev: string) {
setEvents(prev => prev.includes(ev) ? prev.filter(e => e !== ev) : [...prev, ev])
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setFormError('')
if (!url.trim()) return setFormError('URL is required')
if (events.length === 0) return setFormError('Select at least one event')
try {
const payload = { title: title.trim(), url: url.trim(), secret: secret || undefined, events: events.join(','), active }
if (editId) {
await updateHook.mutateAsync({ id: editId, ...payload })
} else {
await createHook.mutateAsync(payload)
}
resetForm()
} catch (err) { setFormError((err as Error).message) }
}
async function handleTest(id: number) {
const result = await testHook.mutateAsync(id)
setTestResult(prev => ({ ...prev, [id]: result }))
setTimeout(() => setTestResult(prev => { const n = { ...prev }; delete n[id]; return n }), 5000)
}
function statusBadge(status: number) {
if (status === 0) return <span className="text-[10px] text-[var(--c-muted)]">Never delivered</span>
const ok = status >= 200 && status < 300
return (
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full ${ok ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-[var(--c-danger-tint)] text-[var(--c-danger)]'}`}>
{status}
</span>
)
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Webhooks</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">Send HTTP POST notifications to external services when events occur.</p>
</div>
{!showForm && (
<button onClick={() => setShowForm(true)}
className="px-3 py-1.5 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] shrink-0">
Add webhook
</button>
)}
</div>
<div className="border-t border-[var(--c-border)]" />
{/* Webhook list */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
{isLoading ? (
<div className="p-4 space-y-3">{[1,2].map(i => <Skeleton key={i} className="h-10 w-full" />)}</div>
) : !hooks?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No webhooks yet.</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{hooks.map(wh => (
<li key={wh.id} className="px-4 py-3 space-y-2">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full shrink-0 ${wh.active ? 'bg-[var(--c-success)]' : 'bg-[var(--c-subtle)]'}`} />
<div className="flex-1 min-w-0">
{wh.title && <p className="text-sm font-medium text-[var(--c-text)]">{wh.title}</p>}
<p className="text-xs text-[var(--c-muted)] font-mono truncate">{wh.url}</p>
</div>
{statusBadge(wh.lastStatus)}
{testResult[wh.id] && (
<span className={`text-[10px] font-semibold ${testResult[wh.id].ok ? 'text-[var(--c-success)]' : 'text-[var(--c-danger)]'}`}>
Test: {testResult[wh.id].status || 'failed'}
</span>
)}
</div>
<div className="flex items-center gap-2 ml-5">
<div className="flex gap-1 flex-wrap">
{wh.events.split(',').map(ev => (
<span key={ev} className="text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded bg-[var(--c-surface-muted)] text-[var(--c-muted)]">{ev}</span>
))}
</div>
<div className="ml-auto flex items-center gap-1">
<button onClick={() => handleTest(wh.id)} disabled={testHook.isPending}
className="text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 rounded hover:bg-[var(--c-surface-muted)] disabled:opacity-40">
Test
</button>
<button onClick={() => startEdit(wh)}
className="text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 rounded hover:bg-[var(--c-surface-muted)]">
Edit
</button>
<button onClick={() => deleteHook.mutate(wh.id)} disabled={deleteHook.isPending}
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] disabled:opacity-40">
<svg width="13" height="13" 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>
</div>
</li>
))}
</ul>
)}
</div>
{/* Add / Edit form */}
{showForm && (
<div className="border border-[var(--c-brand-focus)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
<h2 className="text-sm font-semibold text-[var(--c-text)]">{editId ? 'Edit webhook' : 'Add webhook'}</h2>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Title <span className="text-[var(--c-subtle)]">(optional)</span></label>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Slack notifications"
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>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Payload URL <span className="text-[var(--c-danger)]">*</span></label>
<input value={url} onChange={e => setUrl(e.target.value)} placeholder="https://example.com/webhook"
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>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Secret <span className="text-[var(--c-subtle)]">(optional — used for HMAC-SHA256 signature)</span></label>
<input type="password" value={secret} onChange={e => setSecret(e.target.value)} placeholder={editId ? 'Leave blank to keep existing' : ''}
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>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-2">Events</label>
<div className="flex gap-3 flex-wrap">
{ALL_EVENTS.map(ev => (
<label key={ev} className="flex items-center gap-1.5 cursor-pointer text-sm text-[var(--c-text)]">
<input type="checkbox" checked={events.includes(ev)} onChange={() => toggleEvent(ev)} className="w-4 h-4 accent-[var(--c-brand)]" />
{ev}
</label>
))}
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer text-sm text-[var(--c-text)]">
<input type="checkbox" checked={active} onChange={e => setActive(e.target.checked)} className="w-4 h-4 accent-[var(--c-brand)]" />
Active
</label>
{formError && <p className="text-xs text-[var(--c-danger)]">{formError}</p>}
<div className="flex gap-2">
<button type="submit" disabled={createHook.isPending || updateHook.isPending}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
{(createHook.isPending || updateHook.isPending) ? 'Saving' : editId ? 'Update webhook' : 'Add webhook'}
</button>
<button type="button" onClick={resetForm}
className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
Cancel
</button>
</div>
</form>
</div>
)}
</div>
)
}
// ─── Default reviewers ────────────────────────────────────────────────────────
function DefaultReviewersSection({ owner, repo }: { owner: string; repo: string }) {
const { data: reviewers = [], isLoading } = useDefaultReviewers(owner, repo)
const addReviewer = useAddDefaultReviewer(owner, repo)
const removeReviewer = useRemoveDefaultReviewer(owner, repo)
const [username, setUsername] = useState('')
const [error, setError] = useState('')
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
setError('')
const u = username.trim()
if (!u) return
try {
await addReviewer.mutateAsync(u)
setUsername('')
} catch (err: any) {
setError(err.message ?? 'Failed to add reviewer')
}
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Default reviewers</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">These users are automatically added as reviewers whenever a pull request is opened in this repository.</p>
</div>
<form onSubmit={handleAdd} className="flex gap-2">
<input
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Username"
className="flex-1 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)]"
/>
<button
type="submit"
disabled={addReviewer.isPending || !username.trim()}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
>
{addReviewer.isPending ? 'Adding' : 'Add reviewer'}
</button>
</form>
{error && <p className="text-xs text-[var(--c-danger)]">{error}</p>}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-3">
{[0,1,2].map(i => <Skeleton key={i} className="h-10 rounded" />)}
</div>
) : reviewers.length === 0 ? (
<div className="p-8 text-center text-sm text-[var(--c-muted)]">No default reviewers configured.</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{reviewers.map(rv => (
<li key={rv.userId} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
{rv.avatarUrl ? (
<img src={rv.avatarUrl} alt={rv.username} className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-[var(--c-brand)] flex items-center justify-center text-white text-xs font-semibold">
{rv.username.slice(0, 1).toUpperCase()}
</div>
)}
<span className="text-sm font-medium text-[var(--c-text)]">{rv.username}</span>
</div>
<button
onClick={() => removeReviewer.mutate(rv.username)}
disabled={removeReviewer.isPending}
className="text-xs text-[var(--c-danger)] hover:underline disabled:opacity-50"
>
Remove
</button>
</li>
))}
</ul>
)}
</div>
</div>
)
}
// ─── Default description ──────────────────────────────────────────────────────
function DefaultDescriptionSection({ owner, repo }: { owner: string; repo: string }) {
const { data, isLoading } = useDefaultDescription(owner, repo)
const updateDesc = useUpdateDefaultDescription(owner, repo)
const [template, setTemplate] = useState('')
const [saved, setSaved] = useState(false)
useEffect(() => {
if (data) setTemplate(data.template ?? '')
}, [data])
async function handleSave(e: React.FormEvent) {
e.preventDefault()
await updateDesc.mutateAsync(template)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Default description</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">This template pre-fills the pull request body when a new PR is opened. Supports Markdown.</p>
</div>
{isLoading ? (
<Skeleton className="h-48 rounded" />
) : (
<form onSubmit={handleSave} className="space-y-4">
<textarea
value={template}
onChange={e => setTemplate(e.target.value)}
rows={12}
placeholder={'## Summary\n\n## Test plan\n\n## Screenshots'}
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 className="flex items-center gap-3">
<button
type="submit"
disabled={updateDesc.isPending}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
>
{updateDesc.isPending ? 'Saving' : 'Save template'}
</button>
{saved && <span className="text-xs text-[var(--c-success)]">Saved</span>}
</div>
</form>
)}
</div>
)
}
// ─── Excluded files ───────────────────────────────────────────────────────────
function ExcludedFilesSection({ owner, repo }: { owner: string; repo: string }) {
const { data, isLoading } = useExcludedFiles(owner, repo)
const updateFiles = useUpdateExcludedFiles(owner, repo)
const [patterns, setPatterns] = useState('')
const [saved, setSaved] = useState(false)
useEffect(() => {
if (data) setPatterns(data.patterns ?? '')
}, [data])
async function handleSave(e: React.FormEvent) {
e.preventDefault()
await updateFiles.mutateAsync(patterns)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Excluded files</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Files matching these glob patterns are excluded from pull request diff views. One pattern per line.
Example: <code className="text-xs bg-[var(--c-surface-muted)] px-1 py-0.5 rounded font-mono">package-lock.json</code>
</p>
</div>
{isLoading ? (
<Skeleton className="h-48 rounded" />
) : (
<form onSubmit={handleSave} className="space-y-4">
<textarea
value={patterns}
onChange={e => setPatterns(e.target.value)}
rows={10}
placeholder={'package-lock.json\nyarn.lock\ndist/**\n*.min.js'}
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 className="text-xs text-[var(--c-muted)]">
Patterns use glob syntax. <code className="font-mono">*</code> matches any file, <code className="font-mono">**</code> matches any path segment.
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={updateFiles.isPending}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
>
{updateFiles.isPending ? 'Saving…' : 'Save patterns'}
</button>
{saved && <span className="text-xs text-[var(--c-success)]">Saved</span>}
</div>
</form>
)}
</div>
)
}
// ─── Git LFS ─────────────────────────────────────────────────────────────────
function GitLFSSection({ owner, repo }: { owner: string; repo: string }) {
const { data, isLoading } = useLFSSettings(owner, repo)
const update = useUpdateLFSSettings(owner, repo)
const [maxSizeInput, setMaxSizeInput] = useState('')
const [sizeError, setSizeError] = useState('')
const [saved, setSaved] = useState(false)
useEffect(() => {
if (data) setMaxSizeInput(data.maxFileSizeMB === 0 ? '' : String(data.maxFileSizeMB))
}, [data])
async function toggle(field: 'enabled' | 'lockingEnabled', value: boolean) {
await update.mutateAsync({ [field]: value })
}
async function saveMaxSize(e: React.FormEvent) {
e.preventDefault()
setSizeError('')
const raw = maxSizeInput.trim()
const mb = raw === '' ? 0 : parseInt(raw, 10)
if (raw !== '' && (isNaN(mb) || mb < 0)) {
setSizeError('Enter a positive number of megabytes, or leave blank for unlimited.')
return
}
await update.mutateAsync({ maxFileSizeMB: mb })
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
if (isLoading) {
return (
<div className="max-w-2xl px-6 py-6 space-y-4">
<Skeleton className="h-6 w-48 rounded" />
<Skeleton className="h-24 rounded" />
<Skeleton className="h-24 rounded" />
</div>
)
}
const enabled = data?.enabled ?? false
const lockingEnabled = data?.lockingEnabled ?? true
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Git LFS</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Git Large File Storage replaces large binary files (images, videos, datasets) with lightweight text pointers inside Git,
while storing the actual file content on the server.
</p>
</div>
{/* Enable / disable */}
<div className="border border-[var(--c-border)] rounded-lg divide-y divide-[var(--c-border)]">
<div className="flex items-start justify-between gap-4 px-4 py-4">
<div>
<p className="text-sm font-medium text-[var(--c-text)]">Enable Git LFS</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Allow contributors to push large files using the LFS protocol.
</p>
</div>
<button
onClick={() => toggle('enabled', !enabled)}
disabled={update.isPending}
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${enabled ? 'bg-[var(--c-brand)]' : 'bg-[var(--c-border)]'}`}
>
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div className={`flex items-start justify-between gap-4 px-4 py-4 transition-opacity ${enabled ? '' : 'opacity-40 pointer-events-none'}`}>
<div>
<p className="text-sm font-medium text-[var(--c-text)]">File locking</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Allow contributors to lock LFS files to prevent conflicting edits on binary assets.
</p>
</div>
<button
onClick={() => toggle('lockingEnabled', !lockingEnabled)}
disabled={update.isPending || !enabled}
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${lockingEnabled ? 'bg-[var(--c-brand)]' : 'bg-[var(--c-border)]'}`}
>
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${lockingEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
{/* Max file size */}
<div className={`space-y-3 transition-opacity ${enabled ? '' : 'opacity-40 pointer-events-none'}`}>
<div>
<p className="text-sm font-medium text-[var(--c-text)]">Maximum file size</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Reject LFS pushes for individual files exceeding this size. Leave blank for no limit.
</p>
</div>
<form onSubmit={saveMaxSize} className="flex items-center gap-2">
<div className="relative">
<input
type="number"
min="1"
value={maxSizeInput}
onChange={e => setMaxSizeInput(e.target.value)}
disabled={!enabled}
placeholder="Unlimited"
className="w-36 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)] disabled:opacity-50 pr-10"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--c-muted)] pointer-events-none">MB</span>
</div>
<button
type="submit"
disabled={update.isPending || !enabled}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
>
{update.isPending ? 'Saving…' : 'Save'}
</button>
{saved && <span className="text-xs text-[var(--c-success)]">Saved</span>}
</form>
{sizeError && <p className="text-xs text-[var(--c-danger)]">{sizeError}</p>}
</div>
{/* Info box */}
<div className="rounded-lg bg-[var(--c-surface-muted)] border border-[var(--c-border)] px-4 py-3 flex gap-3">
<svg className="shrink-0 mt-0.5 text-[var(--c-brand)]" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
<p className="text-xs text-[var(--c-muted)] leading-relaxed">
Git LFS must be <a href="https://git-lfs.com" target="_blank" rel="noreferrer" className="text-[var(--c-brand)] hover:underline">installed on the client</a> to push and pull tracked files.
Add a <code className="font-mono bg-[var(--c-surface)] px-1 rounded">.gitattributes</code> file to your repository to specify which file patterns LFS should track.
</p>
</div>
</div>
)
}
// ─── Coming soon ──────────────────────────────────────────────────────────────
function ComingSoon({ sectionId }: { sectionId: SectionId }) {
const meta = SECTION_META[sectionId]
return (
<div className="max-w-2xl px-6 py-6">
<h1 className="text-xl font-semibold text-[var(--c-text)] mb-1">{meta.title}</h1>
<div className="mt-8 flex flex-col items-center justify-center py-16 border border-dashed border-[var(--c-border)] rounded-lg text-center bg-[var(--c-surface-raised)]">
<svg width="40" height="40" fill="none" stroke="var(--c-subtle)" strokeWidth="1.2" viewBox="0 0 24 24" className="mb-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
</svg>
<h2 className="text-base font-semibold text-[var(--c-text)] mb-2">{meta.title}</h2>
<p className="text-sm text-[var(--c-muted)] max-w-sm leading-relaxed">{meta.description}</p>
<span className="mt-5 text-[10px] font-semibold uppercase tracking-wider text-white bg-[var(--c-subtle)] px-2.5 py-1 rounded-full">Coming soon</span>
</div>
</div>
)
}