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 } from "@/lib/utils"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; import { Button } from "@/components/ui/button"; import { Search, RefreshCw, FlipHorizontal } from "lucide-react"; import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror"; 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 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 [isLoading, setIsLoading] = useState(true); const { user } = useAuth(); const { registerRefreshCallback } = useLiveRefresh(); const { isGitHubConfigured } = useConfigStatus(); const { navigationKey } = useNavigation(); const { filter, setFilter } = useFilterParams({ searchTerm: "", status: "", organization: "", owner: "", }); const [isDialogOpen, setIsDialogOpen] = useState(false); // 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 // 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 ) ); } console.log("Received new log:", data); }, []); // Use the SSE hook const { connected } = useSSE({ userId: user?.id, onMessage: handleNewMessage, }); const fetchRepositories = useCallback(async () => { if (!user?.id) return; // Don't fetch repositories if GitHub is not configured or still loading config if (!isGitHubConfigured) { setIsLoading(false); return false; } try { setIsLoading(true); const response = await apiRequest( `/github/repositories?userId=${user.id}`, { method: "GET", } ); if (response.success) { setRepositories(response.repositories); return true; } else { toast.error(response.error || "Error fetching repositories"); return false; } } catch (error) { toast.error( error instanceof Error ? error.message : "Error fetching repositories" ); return false; } finally { setIsLoading(false); } }, [user?.id, isGitHubConfigured]); // Only depend on user.id, not entire user object useEffect(() => { // Reset loading state when component becomes active setIsLoading(true); fetchRepositories(); }, [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(); }); return unregister; }, [registerRefreshCallback, fetchRepositories, isGitHubConfigured]); const handleRefresh = async () => { const success = await fetchRepositories(); 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) { toast.success(`Mirroring started for repository ID: ${repoId}`); setRepositories((prevRepos) => prevRepos.map((repo) => { const updated = response.repositories.find((r) => r.id === repo.id); return updated ? updated : repo; }) ); } else { toast.error(response.error || "Error starting mirror job"); } } catch (error) { toast.error( error instanceof Error ? error.message : "Error starting mirror job" ); } 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 to avoid duplicate operations. also filter out mirrored (mirrored can be synced and not mirrored again) const eligibleRepos = repositories.filter( (repo) => repo.status !== "mirroring" && repo.status !== "mirrored" && repo.id //not ignoring failed ones because we want to retry them if not mirrored. if mirrored, gitea fucnion handlers will silently ignore them ); 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 { toast.error(response.error || "Error starting mirror jobs"); } } catch (error) { toast.error( error instanceof Error ? error.message : "Error starting mirror jobs" ); } finally { // Reset loading states - we'll let the SSE updates handle status changes 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) { toast.success(`Syncing started for repository ID: ${repoId}`); setRepositories((prevRepos) => prevRepos.map((repo) => { const updated = response.repositories.find((r) => r.id === repo.id); return updated ? updated : repo; }) ); } else { toast.error(response.error || "Error starting sync job"); } } catch (error) { toast.error( error instanceof Error ? error.message : "Error starting sync job" ); } 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) { toast.success(`Retrying job for repository ID: ${repoId}`); setRepositories((prevRepos) => prevRepos.map((repo) => { const updated = response.repositories.find((r) => r.id === repo.id); return updated ? updated : repo; }) ); } else { toast.error(response.error || "Error retrying job"); } } catch (error) { toast.error( error instanceof Error ? error.message : "Error retrying job" ); } finally { setLoadingRepoIds((prev) => { const newSet = new Set(prev); newSet.delete(repoId); return newSet; }); } }; const handleAddRepository = async ({ repo, owner, }: { repo: string; owner: string; }) => { try { if (!user || !user.id) { return; } const reqPayload: AddRepositoriesApiRequest = { userId: user.id, repo, owner, }; const response = await apiRequest( "/sync/repository", { method: "POST", data: reqPayload, } ); if (response.success) { toast.success(`Repository added successfully`); setRepositories((prevRepos) => [...prevRepos, response.repository]); await fetchRepositories(); setFilter((prev) => ({ ...prev, searchTerm: repo, })); } else { toast.error(response.error || "Error adding repository"); } } catch (error) { toast.error( error instanceof Error ? error.message : "Error adding repository" ); } }; // 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(); return (
{/* Combine search and actions into a single flex row */}
setFilter((prev) => ({ ...prev, searchTerm: e.target.value })) } />
{/* Owner Combobox */} setFilter((prev) => ({ ...prev, owner })) } /> {/* Organization Combobox */} setFilter((prev) => ({ ...prev, organization })) } />
{!isGitHubConfigured ? (

GitHub Not Configured

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

) : ( )}
); }