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 } from "lucide-react"; import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror"; import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer"; 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 [isInitialLoading, setIsInitialLoading] = useState(true); const { user } = useAuth(); const { registerRefreshCallback, isLiveEnabled } = useLiveRefresh(); const { isGitHubConfigured, isFullyConfigured } = 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 // 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 ); 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" ); 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 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 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(false); // Manual refresh after adding repository setFilter((prev) => ({ ...prev, searchTerm: repo, })); } else { showErrorToast(response.error || "Error adding repository", toast); } } catch (error) { showErrorToast(error, toast); } }; // 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(); // 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")) { actions.push('mirror'); } // Check if any selected repos can be synced if (selectedRepos.some(repo => repo.status === "mirrored" || repo.status === "synced")) { actions.push('sync'); } // 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").length, sync: selectedRepos.filter(repo => repo.status === "mirrored" || repo.status === "synced").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('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('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); }} /> )}
); }