import { useMemo, useRef } from "react"; import Fuse from "fuse.js"; import { useVirtualizer } from "@tanstack/react-virtual"; import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, Trash2 } from "lucide-react"; import { SiGithub, SiGitea } from "react-icons/si"; import type { Repository } from "@/lib/db/schema"; import { Button } from "@/components/ui/button"; import { formatDate, formatLastSyncTime, getStatusColor } from "@/lib/utils"; import type { FilterParams } from "@/types/filter"; import { Skeleton } from "@/components/ui/skeleton"; import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { Checkbox } from "@/components/ui/checkbox"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { InlineDestinationEditor } from "./InlineDestinationEditor"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; interface RepositoryTableProps { repositories: Repository[]; isLoading: boolean; isLiveActive?: boolean; filter: FilterParams; setFilter: (filter: FilterParams) => void; onMirror: ({ repoId }: { repoId: string }) => Promise; onSync: ({ repoId }: { repoId: string }) => Promise; onRetry: ({ repoId }: { repoId: string }) => Promise; onSkip: ({ repoId, skip }: { repoId: string; skip: boolean }) => Promise; loadingRepoIds: Set; selectedRepoIds: Set; onSelectionChange: (selectedIds: Set) => void; onRefresh?: () => Promise; onDelete?: (repoId: string) => void; } export default function RepositoryTable({ repositories, isLoading, isLiveActive = false, filter, setFilter, onMirror, onSync, onRetry, onSkip, loadingRepoIds, selectedRepoIds, onSelectionChange, onRefresh, onDelete, }: RepositoryTableProps) { const tableParentRef = useRef(null); const { giteaConfig } = useGiteaConfig(); const handleUpdateDestination = async (repoId: string, newDestination: string | null) => { // Call API to update repository destination const response = await fetch(`/api/repositories/${repoId}`, { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ destinationOrg: newDestination, }), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || "Failed to update repository"); } // Refresh repositories data if (onRefresh) { await onRefresh(); } }; // Helper function to construct Gitea repository URL const getGiteaRepoUrl = (repository: Repository): string | null => { if (!giteaConfig?.url) { return null; } // Only provide Gitea links for repositories that have been or are being mirrored const validStatuses = ['mirroring', 'mirrored', 'syncing', 'synced', 'archived']; if (!validStatuses.includes(repository.status)) { return null; } // Use mirroredLocation if available, otherwise construct from repository data let repoPath: string; if (repository.mirroredLocation) { repoPath = repository.mirroredLocation; } else { // Fallback: construct the path based on repository data const owner = repository.organization || repository.owner; repoPath = `${owner}/${repository.name}`; } // Ensure the base URL doesn't have a trailing slash const baseUrl = giteaConfig.url.endsWith('/') ? giteaConfig.url.slice(0, -1) : giteaConfig.url; return `${baseUrl}/${repoPath}`; }; const hasAnyFilter = Object.values(filter).some( (val) => val?.toString().trim() !== "" ); const filteredRepositories = useMemo(() => { let result = repositories; if (filter.status) { result = result.filter((repo) => repo.status === filter.status); } if (filter.owner) { result = result.filter((repo) => repo.owner === filter.owner); } if (filter.organization) { result = result.filter( (repo) => repo.organization === filter.organization ); } if (filter.searchTerm) { const fuse = new Fuse(result, { keys: ["name", "fullName", "owner", "organization"], threshold: 0.3, }); result = fuse.search(filter.searchTerm).map((res) => res.item); } return result; }, [repositories, filter]); const rowVirtualizer = useVirtualizer({ count: filteredRepositories.length, getScrollElement: () => tableParentRef.current, estimateSize: () => 65, overscan: 5, }); // Selection handlers const handleSelectAll = (checked: boolean) => { if (checked) { const allIds = new Set(filteredRepositories.map(repo => repo.id).filter((id): id is string => !!id)); onSelectionChange(allIds); } else { onSelectionChange(new Set()); } }; const handleSelectRepo = (repoId: string, checked: boolean) => { const newSelection = new Set(selectedRepoIds); if (checked) { newSelection.add(repoId); } else { newSelection.delete(repoId); } onSelectionChange(newSelection); }; const isAllSelected = filteredRepositories.length > 0 && filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id)); const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected; // Mobile card layout for repository const RepositoryCard = ({ repo }: { repo: Repository }) => { const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false; const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false; const giteaUrl = getGiteaRepoUrl(repo); return (
{/* Header with checkbox and repo name */}
repo.id && handleSelectRepo(repo.id, checked as boolean)} className="mt-1 h-5 w-5" aria-label={`Select ${repo.name}`} />

{repo.name}

{repo.isPrivate && Private} {repo.isForked && Fork} {repo.isStarred && Starred}
{/* Repository details */}
{/* Owner & Organization */}
Owner: {repo.owner}
{repo.organization && (
Org: {repo.organization}
)} {repo.destinationOrg && (
Dest: {repo.destinationOrg}
)}
{/* Status & Last Mirrored */}
{repo.status} {formatLastSyncTime(repo.lastMirrored)}
{/* Actions */}
{/* Primary action button */} {(repo.status === "imported" || repo.status === "failed") && ( )} {(repo.status === "mirrored" || repo.status === "synced") && ( )} {repo.status === "failed" && ( )} {/* Ignore/Include button */} {repo.status === "ignored" ? ( ) : ( )} {/* External links */}
{giteaUrl ? ( ) : ( )}
); }; return isLoading ? (
{/* Mobile skeleton */}
{Array.from({ length: 5 }).map((_, i) => (
))}
{/* Desktop skeleton */}
Repository
Owner
Organization
Last Mirrored
Status
Actions
Links
{Array.from({ length: 5 }).map((_, i) => (
))}
) : (
{hasAnyFilter && (
Showing {filteredRepositories.length} of {repositories.length} repositories
)} {filteredRepositories.length === 0 ? (

{hasAnyFilter ? "No repositories match the current filters" : "No repositories found"}

) : ( <> {/* Mobile card view */}
{/* Select all checkbox */}
Select All ({filteredRepositories.length})
{/* Repository cards */} {filteredRepositories.map((repo) => ( ))}
{/* Desktop table view */}
{/* Table header */}
Repository
Owner
Organization
Last Mirrored
Status
Actions
Links
{/* Table body wrapper (for a parent in virtualization) */}
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => { const repo = filteredRepositories[virtualRow.index]; const isLoading = loadingRepoIds.has(repo.id ?? ""); return (
{/* Checkbox */}
repo.id && handleSelectRepo(repo.id, !!checked)} aria-label={`Select ${repo.name}`} />
{/* Repository */}
{repo.name} {repo.isStarred && ( )}
{repo.fullName}
{repo.isPrivate && ( Private )} {repo.isForked && ( Fork )}
{/* Owner */}

{repo.owner}

{/* Organization */}
{/* Last Mirrored */}

{formatLastSyncTime(repo.lastMirrored)}

{/* Status */}
{repo.status === "failed" && repo.errorMessage ? ( {repo.status}

{repo.errorMessage}

) : ( {repo.status} )}
{/* Actions */}
onMirror({ repoId: repo.id ?? "" })} onSync={() => onSync({ repoId: repo.id ?? "" })} onRetry={() => onRetry({ repoId: repo.id ?? "" })} onSkip={(skip) => onSkip({ repoId: repo.id ?? "", skip })} onDelete={onDelete && repo.id ? () => onDelete(repo.id as string) : undefined} />
{/* Links */}
{(() => { const giteaUrl = getGiteaRepoUrl(repo); // Determine tooltip based on status and configuration let tooltip: string; if (!giteaConfig?.url) { tooltip = "Gitea not configured"; } else if (repo.status === 'imported') { tooltip = "Repository not yet mirrored to Gitea"; } else if (repo.status === 'failed') { tooltip = "Repository mirroring failed"; } else if (repo.status === 'mirroring') { tooltip = "Repository is being mirrored to Gitea"; } else if (giteaUrl) { tooltip = "View on Gitea"; } else { tooltip = "Gitea repository not available"; } return giteaUrl ? ( ) : ( ); })()}
); })}
{/* Status Bar */}
{hasAnyFilter ? `Showing ${filteredRepositories.length} of ${repositories.length} repositories` : `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
{/* Center - Live active indicator */} {isLiveActive && (
Live active
)} {hasAnyFilter && ( Filters applied )}
)}
); } function RepoActionButton({ repo, isLoading, onMirror, onSync, onRetry, onSkip, onDelete, }: { repo: { id: string; status: string }; isLoading: boolean; onMirror: () => void; onSync: () => void; onRetry: () => void; onSkip: (skip: boolean) => void; onDelete?: () => void; }) { // For ignored repos, show an "Include" action if (repo.status === "ignored") { return ( ); } // For actionable statuses, show action + dropdown for skip let primaryLabel = ""; let primaryIcon = <>; let primaryOnClick = () => {}; let primaryDisabled = isLoading; let showPrimaryAction = true; if (repo.status === "failed") { primaryLabel = "Retry"; primaryIcon = ; primaryOnClick = onRetry; } else if (["mirrored", "synced", "syncing", "archived"].includes(repo.status)) { primaryLabel = repo.status === "archived" ? "Manual Sync" : "Sync"; primaryIcon = ; primaryOnClick = onSync; primaryDisabled ||= repo.status === "syncing"; } else if (["imported", "mirroring"].includes(repo.status)) { primaryLabel = "Mirror"; primaryIcon = ; primaryOnClick = onMirror; primaryDisabled ||= repo.status === "mirroring"; } else { showPrimaryAction = false; } // If there's no primary action, just show ignore button if (!showPrimaryAction) { return ( ); } // Show primary action with dropdown for additional actions return (
onSkip(true)}> Ignore Repository {onDelete && ( <> Delete from Mirror )}
); }