import { useCallback, useEffect, useState } from "react"; import RepositoryTable from "./RepositoryTable"; import type { MirrorJob, Repository } from "@/lib/db/schema"; import { useAuth } from "@/hooks/useAuth"; import { repoStatusEnum, type AddRepositoriesApiRequest, type AddRepositoriesApiResponse, type RepositoryApiResponse, type RepoStatus, } from "@/types/Repository"; import { apiRequest, showErrorToast, getStatusColor } from "@/lib/utils"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; import { Button } from "@/components/ui/button"; import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter, Ban, Check, LoaderCircle, Trash2 } from "lucide-react"; import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror"; import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { useSSE } from "@/hooks/useSEE"; import { useFilterParams } from "@/hooks/useFilterParams"; import { toast } from "sonner"; import type { SyncRepoRequest, SyncRepoResponse } from "@/types/sync"; import { OwnerCombobox, OrganizationCombobox } from "./RepositoryComboboxes"; import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry"; import type { ResetMetadataRequest, ResetMetadataResponse } from "@/types/reset-metadata"; import AddRepositoryDialog from "./AddRepositoryDialog"; import { useLiveRefresh } from "@/hooks/useLiveRefresh"; import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useNavigation } from "@/components/layout/MainLayout"; export default function Repository() { const [repositories, setRepositories] = useState([]); const [isInitialLoading, setIsInitialLoading] = useState(true); const { user } = useAuth(); const { registerRefreshCallback, isLiveEnabled } = useLiveRefresh(); const { isGitHubConfigured, isFullyConfigured, autoMirrorStarred, githubOwner } = useConfigStatus(); const { navigationKey } = useNavigation(); const { filter, setFilter } = useFilterParams({ searchTerm: "", status: "", organization: "", owner: "", }); const [isDialogOpen, setIsDialogOpen] = useState(false); const [selectedRepoIds, setSelectedRepoIds] = useState>(new Set()); // Read organization filter from URL when component mounts useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const orgParam = urlParams.get("organization"); if (orgParam) { setFilter((prev) => ({ ...prev, organization: orgParam })); } }, [setFilter]); const [loadingRepoIds, setLoadingRepoIds] = useState>(new Set()); // this is used when the api actions are performed const [duplicateRepoCandidate, setDuplicateRepoCandidate] = useState<{ owner: string; repo: string; } | null>(null); const [isDuplicateRepoDialogOpen, setIsDuplicateRepoDialogOpen] = useState(false); const [isProcessingDuplicateRepo, setIsProcessingDuplicateRepo] = useState(false); const [repoToDelete, setRepoToDelete] = useState(null); const [isDeleteRepoDialogOpen, setIsDeleteRepoDialogOpen] = useState(false); const [isDeletingRepo, setIsDeletingRepo] = useState(false); // Create a stable callback using useCallback const handleNewMessage = useCallback((data: MirrorJob) => { if (data.repositoryId) { setRepositories((prevRepos) => prevRepos.map((repo) => repo.id === data.repositoryId ? { ...repo, status: data.status, details: data.details } : repo ) ); } }, []); // Use the SSE hook const { connected } = useSSE({ userId: user?.id, onMessage: handleNewMessage, }); const fetchRepositories = useCallback(async (isLiveRefresh = false) => { if (!user?.id) return; // Don't fetch repositories if GitHub is not configured or still loading config if (!isGitHubConfigured) { setIsInitialLoading(false); return false; } try { // Set appropriate loading state based on refresh type if (!isLiveRefresh) { setIsInitialLoading(true); } const response = await apiRequest( `/github/repositories?userId=${user.id}`, { method: "GET", } ); if (response.success) { setRepositories(response.repositories); return true; } else { // Only show error toast for manual refreshes to avoid spam during live updates if (!isLiveRefresh) { showErrorToast(response.error || "Error fetching repositories", toast); } return false; } } catch (error) { // Only show error toast for manual refreshes to avoid spam during live updates if (!isLiveRefresh) { showErrorToast(error, toast); } return false; } finally { if (!isLiveRefresh) { setIsInitialLoading(false); } } }, [user?.id, isGitHubConfigured]); // Only depend on user.id, not entire user object useEffect(() => { // Reset loading state when component becomes active setIsInitialLoading(true); fetchRepositories(false); // Manual refresh, not live }, [fetchRepositories, navigationKey]); // Include navigationKey to trigger on navigation // Register with global live refresh system useEffect(() => { // Only register for live refresh if GitHub is configured if (!isGitHubConfigured) { return; } const unregister = registerRefreshCallback(() => { fetchRepositories(true); // Live refresh }); return unregister; }, [registerRefreshCallback, fetchRepositories, isGitHubConfigured]); const handleRefresh = async () => { const success = await fetchRepositories(false); // Manual refresh, show loading skeleton if (success) { toast.success("Repositories refreshed successfully."); } }; const handleMirrorRepo = async ({ repoId }: { repoId: string }) => { try { if (!user || !user.id) { return; } setLoadingRepoIds((prev) => new Set(prev).add(repoId)); const reqPayload: MirrorRepoRequest = { userId: user.id, repositoryIds: [repoId], }; const response = await apiRequest( "/job/mirror-repo", { method: "POST", data: reqPayload, } ); if (response.success) { const repo = repositories.find(r => r.id === repoId); const repoName = repo?.fullName || `repository ${repoId}`; toast.success(`Mirroring started for ${repoName}`); setRepositories((prevRepos) => prevRepos.map((repo) => { const updated = response.repositories.find((r) => r.id === repo.id); return updated ? updated : repo; }) ); } else { showErrorToast(response.error || "Error starting mirror job", toast); } } catch (error) { showErrorToast(error, toast); } finally { setLoadingRepoIds((prev) => { const newSet = new Set(prev); newSet.delete(repoId); return newSet; }); } }; const handleMirrorAllRepos = async () => { try { if (!user || !user.id || repositories.length === 0) { return; } // Filter out repositories that are already mirroring, mirrored, or ignored const eligibleRepos = repositories.filter( (repo) => repo.status !== "mirroring" && repo.status !== "mirrored" && repo.status !== "ignored" && // Skip ignored repositories repo.id && // Skip starred repos from other owners when autoMirrorStarred is disabled !(repo.isStarred && !autoMirrorStarred && repo.owner !== githubOwner) ); if (eligibleRepos.length === 0) { toast.info("No eligible repositories to mirror"); return; } // Get all repository IDs const repoIds = eligibleRepos.map((repo) => repo.id as string); // Set loading state for all repositories being mirrored setLoadingRepoIds((prev) => { const newSet = new Set(prev); repoIds.forEach((id) => newSet.add(id)); return newSet; }); const reqPayload: MirrorRepoRequest = { userId: user.id, repositoryIds: repoIds, }; const response = await apiRequest( "/job/mirror-repo", { method: "POST", data: reqPayload, } ); if (response.success) { toast.success(`Mirroring started for ${repoIds.length} repositories`); setRepositories((prevRepos) => prevRepos.map((repo) => { const updated = response.repositories.find((r) => r.id === repo.id); return updated ? updated : repo; }) ); } else { showErrorToast(response.error || "Error starting mirror jobs", toast); } } catch (error) { showErrorToast(error, toast); } finally { // Reset loading states - we'll let the SSE updates handle status changes setLoadingRepoIds(new Set()); } }; // Bulk action handlers const handleBulkMirror = async () => { if (selectedRepoIds.size === 0) return; const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id)); const eligibleRepos = selectedRepos.filter( repo => repo.status === "imported" || repo.status === "failed" || repo.status === "pending-approval" ); if (eligibleRepos.length === 0) { toast.info("No eligible repositories to mirror in selection"); return; } const repoIds = eligibleRepos.map(repo => repo.id as string); setLoadingRepoIds(prev => { const newSet = new Set(prev); repoIds.forEach(id => newSet.add(id)); return newSet; }); try { const response = await apiRequest("/job/mirror-repo", { method: "POST", data: { userId: user?.id, repositoryIds: repoIds } }); if (response.success) { toast.success(`Mirroring started for ${repoIds.length} repositories`); setRepositories(prevRepos => prevRepos.map(repo => { const updated = response.repositories.find(r => r.id === repo.id); return updated ? updated : repo; }) ); setSelectedRepoIds(new Set()); } else { showErrorToast(response.error || "Error starting mirror jobs", toast); } } catch (error) { showErrorToast(error, toast); } finally { setLoadingRepoIds(new Set()); } }; const handleBulkSync = async () => { if (selectedRepoIds.size === 0) return; const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id)); const eligibleRepos = selectedRepos.filter( repo => ["mirrored", "synced", "archived"].includes(repo.status) ); if (eligibleRepos.length === 0) { toast.info("No eligible repositories to sync in selection"); return; } const repoIds = eligibleRepos.map(repo => repo.id as string); setLoadingRepoIds(prev => { const newSet = new Set(prev); repoIds.forEach(id => newSet.add(id)); return newSet; }); try { const response = await apiRequest("/job/sync-repo", { method: "POST", data: { userId: user?.id, repositoryIds: repoIds } }); if (response.success) { toast.success(`Syncing started for ${repoIds.length} repositories`); setRepositories(prevRepos => prevRepos.map(repo => { const updated = response.repositories.find(r => r.id === repo.id); return updated ? updated : repo; }) ); setSelectedRepoIds(new Set()); } else { showErrorToast(response.error || "Error starting sync jobs", toast); } } catch (error) { showErrorToast(error, toast); } finally { setLoadingRepoIds(new Set()); } }; const handleBulkRerunMetadata = async () => { if (selectedRepoIds.size === 0) return; const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id)); const eligibleRepos = selectedRepos.filter( repo => ["mirrored", "synced", "archived"].includes(repo.status) ); if (eligibleRepos.length === 0) { toast.info("No eligible repositories to re-run metadata in selection"); return; } const repoIds = eligibleRepos.map(repo => repo.id as string); setLoadingRepoIds(prev => { const newSet = new Set(prev); repoIds.forEach(id => newSet.add(id)); return newSet; }); try { const resetPayload: ResetMetadataRequest = { userId: user?.id || "", repositoryIds: repoIds, }; const resetResponse = await apiRequest("/job/reset-metadata", { method: "POST", data: resetPayload, }); if (!resetResponse.success) { showErrorToast(resetResponse.error || "Failed to reset metadata state", toast); return; } const syncResponse = await apiRequest("/job/sync-repo", { method: "POST", data: { userId: user?.id, repositoryIds: repoIds }, }); if (syncResponse.success) { toast.success(`Re-running metadata for ${repoIds.length} repositories`); setRepositories(prevRepos => prevRepos.map(repo => { const updated = syncResponse.repositories.find(r => r.id === repo.id); return updated ? updated : repo; }) ); setSelectedRepoIds(new Set()); } else { showErrorToast(syncResponse.error || "Error starting metadata re-sync", toast); } } catch (error) { showErrorToast(error, toast); } finally { setLoadingRepoIds(new Set()); } }; const handleBulkRetry = async () => { if (selectedRepoIds.size === 0) return; const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id)); const eligibleRepos = selectedRepos.filter(repo => repo.status === "failed"); if (eligibleRepos.length === 0) { toast.info("No failed repositories in selection to retry"); return; } const repoIds = eligibleRepos.map(repo => repo.id as string); setLoadingRepoIds(prev => { const newSet = new Set(prev); repoIds.forEach(id => newSet.add(id)); return newSet; }); try { const response = await apiRequest("/job/retry-repo", { method: "POST", data: { userId: user?.id, repositoryIds: repoIds } }); if (response.success) { toast.success(`Retrying ${repoIds.length} repositories`); setRepositories(prevRepos => prevRepos.map(repo => { const updated = response.repositories.find(r => r.id === repo.id); return updated ? updated : repo; }) ); setSelectedRepoIds(new Set()); } else { showErrorToast(response.error || "Error retrying jobs", toast); } } catch (error) { showErrorToast(error, toast); } finally { setLoadingRepoIds(new Set()); } }; const handleBulkSkip = async (skip: boolean) => { if (selectedRepoIds.size === 0) return; const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id)); const eligibleRepos = skip ? selectedRepos.filter(repo => repo.status !== "ignored" && repo.status !== "mirroring" && repo.status !== "syncing" ) : selectedRepos.filter(repo => repo.status === "ignored"); if (eligibleRepos.length === 0) { toast.info(`No eligible repositories to ${skip ? "ignore" : "include"} in selection`); return; } const repoIds = eligibleRepos.map(repo => repo.id as string); setLoadingRepoIds(prev => { const newSet = new Set(prev); repoIds.forEach(id => newSet.add(id)); return newSet; }); try { // Update each repository's status const newStatus = skip ? "ignored" : "imported"; const promises = repoIds.map(repoId => apiRequest<{ success: boolean; repository?: Repository; error?: string }>( `/repositories/${repoId}/status`, { method: "PATCH", data: { status: newStatus, userId: user?.id }, } ) ); const results = await Promise.allSettled(promises); const successCount = results.filter(r => r.status === "fulfilled" && (r.value as any).success).length; if (successCount > 0) { toast.success(`${successCount} repositories ${skip ? "ignored" : "included"}`); // Update local state for successful updates const successfulRepoIds = new Set(); results.forEach((result, index) => { if (result.status === "fulfilled" && (result.value as any).success) { successfulRepoIds.add(repoIds[index]); } }); setRepositories(prevRepos => prevRepos.map(repo => { if (repo.id && successfulRepoIds.has(repo.id)) { return { ...repo, status: newStatus as any }; } return repo; }) ); setSelectedRepoIds(new Set()); } if (successCount < repoIds.length) { toast.error(`Failed to ${skip ? "ignore" : "include"} ${repoIds.length - successCount} repositories`); } } catch (error) { showErrorToast(error, toast); } finally { setLoadingRepoIds(new Set()); } }; const handleSyncRepo = async ({ repoId }: { repoId: string }) => { try { if (!user || !user.id) { return; } setLoadingRepoIds((prev) => new Set(prev).add(repoId)); const reqPayload: SyncRepoRequest = { userId: user.id, repositoryIds: [repoId], }; const response = await apiRequest("/job/sync-repo", { method: "POST", data: reqPayload, }); if (response.success) { const repo = repositories.find(r => r.id === repoId); const repoName = repo?.fullName || `repository ${repoId}`; toast.success(`Syncing started for ${repoName}`); setRepositories((prevRepos) => prevRepos.map((repo) => { const updated = response.repositories.find((r) => r.id === repo.id); return updated ? updated : repo; }) ); } else { showErrorToast(response.error || "Error starting sync job", toast); } } catch (error) { showErrorToast(error, toast); } finally { setLoadingRepoIds((prev) => { const newSet = new Set(prev); newSet.delete(repoId); return newSet; }); } }; const handleSkipRepo = async ({ repoId, skip }: { repoId: string; skip: boolean }) => { try { if (!user || !user.id) { return; } // Check if repository is currently being processed const repo = repositories.find(r => r.id === repoId); if (skip && repo && (repo.status === "mirroring" || repo.status === "syncing")) { toast.warning("Cannot skip repository while it's being processed"); return; } // Set loading state setLoadingRepoIds(prev => { const newSet = new Set(prev); newSet.add(repoId); return newSet; }); const newStatus = skip ? "ignored" : "imported"; // Update repository status via API const response = await apiRequest<{ success: boolean; repository?: Repository; error?: string }>( `/repositories/${repoId}/status`, { method: "PATCH", data: { status: newStatus, userId: user.id }, } ); if (response.success && response.repository) { toast.success(`Repository ${skip ? "ignored" : "included"}`); setRepositories(prevRepos => prevRepos.map(repo => repo.id === repoId ? response.repository! : repo ) ); } else { showErrorToast(response.error || `Error ${skip ? "ignoring" : "including"} repository`, toast); } } catch (error) { showErrorToast(error, toast); } finally { setLoadingRepoIds(prev => { const newSet = new Set(prev); newSet.delete(repoId); return newSet; }); } }; const handleRetryRepoAction = async ({ repoId }: { repoId: string }) => { try { if (!user || !user.id) { return; } setLoadingRepoIds((prev) => new Set(prev).add(repoId)); const reqPayload: RetryRepoRequest = { userId: user.id, repositoryIds: [repoId], }; const response = await apiRequest("/job/retry-repo", { method: "POST", data: reqPayload, }); if (response.success) { const repo = repositories.find(r => r.id === repoId); const repoName = repo?.fullName || `repository ${repoId}`; toast.success(`Retrying job for ${repoName}`); setRepositories((prevRepos) => prevRepos.map((repo) => { const updated = response.repositories.find((r) => r.id === repo.id); return updated ? updated : repo; }) ); } else { showErrorToast(response.error || "Error retrying job", toast); } } catch (error) { showErrorToast(error, toast); } finally { setLoadingRepoIds((prev) => { const newSet = new Set(prev); newSet.delete(repoId); return newSet; }); } }; const handleApproveSyncAction = async ({ repoId }: { repoId: string }) => { try { if (!user || !user.id) return; setLoadingRepoIds((prev) => new Set(prev).add(repoId)); const response = await apiRequest<{ success: boolean; message?: string; error?: string; repositories: Repository[]; }>("/job/approve-sync", { method: "POST", data: { repositoryIds: [repoId], action: "approve" }, }); if (response.success) { toast.success("Sync approved — backup + sync started"); setRepositories((prevRepos) => prevRepos.map((repo) => { const updated = response.repositories.find((r) => r.id === repo.id); return updated ? updated : repo; }), ); } else { showErrorToast(response.error || "Error approving sync", toast); } } catch (error) { showErrorToast(error, toast); } finally { setLoadingRepoIds((prev) => { const newSet = new Set(prev); newSet.delete(repoId); return newSet; }); } }; const handleDismissSyncAction = async ({ repoId }: { repoId: string }) => { try { if (!user || !user.id) return; setLoadingRepoIds((prev) => new Set(prev).add(repoId)); const response = await apiRequest<{ success: boolean; message?: string; error?: string; repositories: Repository[]; }>("/job/approve-sync", { method: "POST", data: { repositoryIds: [repoId], action: "dismiss" }, }); if (response.success) { toast.success("Force-push alert dismissed"); setRepositories((prevRepos) => prevRepos.map((repo) => { const updated = response.repositories.find((r) => r.id === repo.id); return updated ? updated : repo; }), ); } else { showErrorToast(response.error || "Error dismissing alert", toast); } } catch (error) { showErrorToast(error, toast); } finally { setLoadingRepoIds((prev) => { const newSet = new Set(prev); newSet.delete(repoId); return newSet; }); } }; const handleAddRepository = async ({ repo, owner, force = false, destinationOrg, }: { repo: string; owner: string; force?: boolean; destinationOrg?: string; }) => { if (!user || !user.id) { return; } const trimmedRepo = repo.trim(); const trimmedOwner = owner.trim(); if (!trimmedRepo || !trimmedOwner) { toast.error("Please provide both owner and repository name."); throw new Error("Invalid repository details"); } const normalizedFullName = `${trimmedOwner}/${trimmedRepo}`.toLowerCase(); if (!force) { const duplicateRepo = repositories.find( (existing) => existing.normalizedFullName?.toLowerCase() === normalizedFullName ); if (duplicateRepo) { toast.warning("Repository already exists."); setDuplicateRepoCandidate({ repo: trimmedRepo, owner: trimmedOwner }); setIsDuplicateRepoDialogOpen(true); throw new Error("Repository already exists"); } } try { const reqPayload: AddRepositoriesApiRequest = { userId: user.id, repo: trimmedRepo, owner: trimmedOwner, force, ...(destinationOrg ? { destinationOrg } : {}), }; const response = await apiRequest( "/sync/repository", { method: "POST", data: reqPayload, } ); if (response.success) { const message = force ? "Repository already exists; metadata refreshed." : "Repository added successfully"; toast.success(message); await fetchRepositories(false); setFilter((prev) => ({ ...prev, searchTerm: trimmedRepo, })); if (force) { setDuplicateRepoCandidate(null); setIsDuplicateRepoDialogOpen(false); } } else { showErrorToast(response.error || "Error adding repository", toast); } } catch (error) { showErrorToast(error, toast); throw error; } }; // Get unique owners and organizations for comboboxes const ownerOptions = Array.from( new Set( repositories.map((repo) => repo.owner).filter((v): v is string => !!v) ) ).sort(); const orgOptions = Array.from( new Set( repositories .map((repo) => repo.organization) .filter((v): v is string => !!v) ) ).sort(); const handleConfirmDuplicateRepository = async () => { if (!duplicateRepoCandidate) { return; } setIsProcessingDuplicateRepo(true); try { await handleAddRepository({ repo: duplicateRepoCandidate.repo, owner: duplicateRepoCandidate.owner, force: true, }); setIsDialogOpen(false); } catch (error) { // Error already shown } finally { setIsProcessingDuplicateRepo(false); } }; const handleCancelDuplicateRepository = () => { setDuplicateRepoCandidate(null); setIsDuplicateRepoDialogOpen(false); }; const handleRequestDeleteRepository = (repoId: string) => { const repo = repositories.find((item) => item.id === repoId); if (!repo) { toast.error("Repository not found"); return; } setRepoToDelete(repo); setIsDeleteRepoDialogOpen(true); }; const handleDeleteRepository = async () => { if (!user || !user.id || !repoToDelete) { return; } setIsDeletingRepo(true); try { const response = await apiRequest<{ success: boolean; error?: string }>( `/repositories/${repoToDelete.id}`, { method: "DELETE", } ); if (response.success) { toast.success(`Removed ${repoToDelete.fullName} from Gitea Mirror.`); await fetchRepositories(false); } else { showErrorToast(response.error || "Failed to delete repository", toast); } } catch (error) { showErrorToast(error, toast); } finally { setIsDeletingRepo(false); setIsDeleteRepoDialogOpen(false); setRepoToDelete(null); } }; // Determine what actions are available for selected repositories const getAvailableActions = () => { if (selectedRepoIds.size === 0) return []; const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id)); const actions = []; // Check if any selected repos can be mirrored if (selectedRepos.some(repo => repo.status === "imported" || repo.status === "failed" || repo.status === "pending-approval")) { actions.push('mirror'); } // Check if any selected repos can be synced if (selectedRepos.some(repo => repo.status === "mirrored" || repo.status === "synced")) { actions.push('sync'); } if (selectedRepos.some(repo => ["mirrored", "synced", "archived"].includes(repo.status))) { actions.push('rerun-metadata'); } // Check if any selected repos are failed if (selectedRepos.some(repo => repo.status === "failed")) { actions.push('retry'); } // Check if any selected repos can be ignored if (selectedRepos.some(repo => repo.status !== "ignored")) { actions.push('ignore'); } // Check if any selected repos can be included (unignored) if (selectedRepos.some(repo => repo.status === "ignored")) { actions.push('include'); } return actions; }; const availableActions = getAvailableActions(); // Get counts for eligible repositories for each action const getActionCounts = () => { const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id)); return { mirror: selectedRepos.filter(repo => repo.status === "imported" || repo.status === "failed" || repo.status === "pending-approval").length, sync: selectedRepos.filter(repo => repo.status === "mirrored" || repo.status === "synced").length, rerunMetadata: selectedRepos.filter(repo => ["mirrored", "synced", "archived"].includes(repo.status)).length, retry: selectedRepos.filter(repo => repo.status === "failed").length, ignore: selectedRepos.filter(repo => repo.status !== "ignored").length, include: selectedRepos.filter(repo => repo.status === "ignored").length, }; }; const actionCounts = getActionCounts(); // Check if any filters are active const hasActiveFilters = !!(filter.owner || filter.organization || filter.status); const activeFilterCount = [filter.owner, filter.organization, filter.status].filter(Boolean).length; // Clear all filters const clearFilters = () => { setFilter({ searchTerm: filter.searchTerm, status: "", organization: "", owner: "", }); }; return (
{/* Search and filters */}
{/* Mobile: Search bar with filter button */}
setFilter((prev) => ({ ...prev, searchTerm: e.target.value })) } />
{/* Mobile Filter Drawer */} Filter Repositories Narrow down your repository list
{/* Active filters summary */} {hasActiveFilters && (
{activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
)} {/* Owner Filter */}
setFilter((prev) => ({ ...prev, owner })) } />
{/* Organization Filter */}
setFilter((prev) => ({ ...prev, organization })) } />
{/* Status Filter */}
{/* Desktop: Original layout */}
setFilter((prev) => ({ ...prev, searchTerm: e.target.value })) } />
{/* Owner Combobox */} setFilter((prev) => ({ ...prev, owner })) } /> {/* Organization Combobox */} setFilter((prev) => ({ ...prev, organization })) } /> {/* Filter controls in a responsive row */}
{/* Bulk actions on desktop - integrated into the same line */}
{selectedRepoIds.size === 0 ? ( ) : ( <>
{selectedRepoIds.size} selected
{availableActions.includes('mirror') && ( )} {availableActions.includes('sync') && ( )} {availableActions.includes('rerun-metadata') && ( )} {availableActions.includes('retry') && ( )} {availableActions.includes('ignore') && ( )} {availableActions.includes('include') && ( )} )}
{/* Action buttons for mobile - only show when items are selected */} {selectedRepoIds.size > 0 && (
{selectedRepoIds.size} selected
{availableActions.includes('mirror') && ( )} {availableActions.includes('sync') && ( )} {availableActions.includes('rerun-metadata') && ( )} {availableActions.includes('retry') && ( )} {availableActions.includes('ignore') && ( )} {availableActions.includes('include') && ( )}
)} {!isGitHubConfigured ? (

GitHub Not Configured

You need to configure your GitHub credentials before you can fetch and mirror repositories.

) : ( { await fetchRepositories(false); }} onDelete={handleRequestDeleteRepository} onApproveSync={handleApproveSyncAction} onDismissSync={handleDismissSyncAction} /> )} { if (!open) { handleCancelDuplicateRepository(); } }} > Repository already exists {duplicateRepoCandidate ? `${duplicateRepoCandidate.owner}/${duplicateRepoCandidate.repo}` : "This repository"} is already tracked in Gitea Mirror. Continuing will refresh the existing entry without creating a duplicate. { if (!open) { setIsDeleteRepoDialogOpen(false); setRepoToDelete(null); } }} > Remove repository from Gitea Mirror? {repoToDelete?.fullName ?? "This repository"} will be deleted from Gitea Mirror only. The mirror on Gitea will remain untouched; remove it manually in Gitea if needed.
); }