Files
gitea-mirror/src/components/repositories/RepositoryTable.tsx

424 lines
14 KiB
TypeScript

import { useMemo, useRef } from "react";
import Fuse from "fuse.js";
import { useVirtualizer } from "@tanstack/react-virtual";
import { GitFork, RefreshCw, RotateCcw } 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, getStatusColor } from "@/lib/utils";
import type { FilterParams } from "@/types/filter";
import { Skeleton } from "@/components/ui/skeleton";
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
interface RepositoryTableProps {
repositories: Repository[];
isLoading: boolean;
filter: FilterParams;
setFilter: (filter: FilterParams) => void;
onMirror: ({ repoId }: { repoId: string }) => Promise<void>;
onSync: ({ repoId }: { repoId: string }) => Promise<void>;
onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
loadingRepoIds: Set<string>;
}
export default function RepositoryTable({
repositories,
isLoading,
filter,
setFilter,
onMirror,
onSync,
onRetry,
loadingRepoIds,
}: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null);
const { giteaConfig } = useGiteaConfig();
// 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'];
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,
});
return isLoading ? (
<div className="border rounded-md">
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Actions
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
Links
</div>
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-[65px] flex items-center justify-between border-b bg-transparent"
>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
<Skeleton className="h-full w-full" />
</div>
</div>
))}
</div>
) : filteredRepositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<GitFork className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No repositories found</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
{hasAnyFilter
? "Try adjusting your search or filter criteria."
: "Configure your GitHub connection to start mirroring repositories."}
</p>
{hasAnyFilter ? (
<Button
variant="outline"
onClick={() =>
setFilter({
searchTerm: "",
status: "",
})
}
>
Clear Filters
</Button>
) : (
<Button asChild>
<a href="/config">Configure GitHub</a>
</Button>
)}
</div>
) : (
<div className="flex flex-col border rounded-md">
{/* table header */}
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Actions
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
Links
</div>
</div>
{/* table body wrapper (for a parent in virtualization) */}
<div
ref={tableParentRef}
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto" //adjusted height to account for status bar
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
const repo = filteredRepositories[virtualRow.index];
const isLoading = loadingRepoIds.has(repo.id ?? "");
return (
<div
key={index}
ref={rowVirtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
}}
data-index={virtualRow.index}
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50" //the height is set according to the row content. right now the highest row is in the repo column which is arround 64.99px
>
{/* Repository */}
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">{repo.name}</div>
<div className="text-xs text-muted-foreground">
{repo.fullName}
</div>
</div>
{repo.isPrivate && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
Private
</span>
)}
</div>
{/* Owner */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">{repo.owner}</p>
</div>
{/* Organization */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm"> {repo.organization || "-"}</p>
</div>
{/* Last Mirrored */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">
{repo.lastMirrored
? formatDate(new Date(repo.lastMirrored))
: "Never"}
</p>
</div>
{/* Status */}
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
repo.status
)}`}
/>
<span className="text-sm capitalize">{repo.status}</span>
</div>
{/* Actions */}
<div className="h-full p-3 flex items-center justify-start flex-[1]">
<RepoActionButton
repo={{ id: repo.id ?? "", status: repo.status }}
isLoading={isLoading}
onMirror={() => onMirror({ repoId: repo.id ?? "" })}
onSync={() => onSync({ repoId: repo.id ?? "" })}
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
/>
</div>
{/* Links */}
<div className="h-full p-3 flex items-center justify-center gap-x-2 flex-[0.8]">
{(() => {
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 ? (
<Button variant="ghost" size="icon" asChild>
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
title={tooltip}
>
<SiGitea className="h-4 w-4" />
</a>
</Button>
) : (
<Button variant="ghost" size="icon" disabled title={tooltip}>
<SiGitea className="h-4 w-4" />
</Button>
);
})()}
<Button variant="ghost" size="icon" asChild>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
</div>
);
})}
</div>
</div>
{/* Status Bar */}
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3">
<div className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
<span className="text-sm font-medium text-foreground">
{hasAnyFilter
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
</span>
</div>
{hasAnyFilter && (
<span className="text-xs text-muted-foreground">
Filters applied
</span>
)}
</div>
</div>
);
}
function RepoActionButton({
repo,
isLoading,
onMirror,
onSync,
onRetry,
}: {
repo: { id: string; status: string };
isLoading: boolean;
onMirror: () => void;
onSync: () => void;
onRetry: () => void;
}) {
let label = "";
let icon = <></>;
let onClick = () => {};
let disabled = isLoading;
if (repo.status === "failed") {
label = "Retry";
icon = <RotateCcw className="h-4 w-4 mr-1" />;
onClick = onRetry;
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
label = "Sync";
icon = <RefreshCw className="h-4 w-4 mr-1" />;
onClick = onSync;
disabled ||= repo.status === "syncing";
} else if (["imported", "mirroring"].includes(repo.status)) {
label = "Mirror";
icon = <GitFork className="h-4 w-4 mr-1" />;
onClick = onMirror;
disabled ||= repo.status === "mirroring";
} else {
return null; // unsupported status
}
return (
<Button
variant="ghost"
disabled={disabled}
onClick={onClick}
className="min-w-[80px] justify-start"
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
{label}
</>
) : (
<>
{icon}
{label}
</>
)}
</Button>
);
}