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 = { '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 (
{/* Sidebar */} {/* Content */}
{section === 'repository-details' && } {section === 'repository-permissions' && } {section === 'access-keys' && } {section === 'access-tokens' && } {section === 'branch-restrictions' && } {section === 'branching-model' && } {section === 'merge-strategies' && } {section === 'webhooks' && } {section === 'default-reviewers' && } {section === 'default-description' && } {section === 'excluded-files' && } {section === 'git-lfs' && } {!['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) && }
) } // ─── 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(null) const [avatarPreview, setAvatarPreview] = useState(null) const manageRef = useRef(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 = { 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 (
) } return (
{/* Page header */}
{owner} / {repo}

Repository details

{/* Manage repository dropdown */}
{showManage && (
)}
{/* Avatar */}
{/* Clickable avatar */}

JPEG, PNG, GIF or WebP · max 5 MB

{uploadAvatar.isError && (

{(uploadAvatar.error as Error).message}

)} {uploadAvatar.isSuccess && !avatarPreview && (

Avatar updated.

)}
{ const f = e.target.files?.[0]; if (f) handleAvatarFile(f) }} />
{/* Repository name */}
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 ?

{nameError}

: name !== repoData.name ?

Renaming will change the clone URL — all existing git remotes will need to be updated.

:

Letters, numbers, hyphens, underscores, and dots only.

}
{/* Size (read-only) */}

{formatSize(repoData.size)}

{/* Description */}