Compare commits

...

3 Commits

Author SHA1 Message Date
Arunavo Ray
7b58df375e chore: bump version to v2.16.2 2025-06-17 17:23:44 +05:30
Arunavo Ray
1d27bd31d8 feat: add bulk actions for repository management with selection support 2025-06-17 17:22:38 +05:30
Arunavo Ray
13d4257c4f refactor: enhance organization card display with status badges and improved layout 2025-06-17 16:52:15 +05:30
5 changed files with 463 additions and 96 deletions

View File

@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.16.2] - 2025-06-17
### Added
- Bulk actions for repository management with selection support
### Improved
- Enhanced organization card display with status badges and improved layout
## [2.16.1] - 2025-06-17 ## [2.16.1] - 2025-06-17
### Improved ### Improved

View File

@@ -1,7 +1,7 @@
{ {
"name": "gitea-mirror", "name": "gitea-mirror",
"type": "module", "type": "module",
"version": "2.16.1", "version": "2.16.2",
"engines": { "engines": {
"bun": ">=1.2.9" "bun": ">=1.2.9"
}, },

View File

@@ -1,14 +1,14 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus, RefreshCw, Building2 } from "lucide-react"; import { Badge } from "@/components/ui/badge";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock } from "lucide-react";
import { SiGithub } from "react-icons/si"; import { SiGithub } from "react-icons/si";
import type { Organization } from "@/lib/db/schema"; import type { Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter"; import type { FilterParams } from "@/types/filter";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils";
import { getStatusColor } from "@/lib/utils";
interface OrganizationListProps { interface OrganizationListProps {
organizations: Organization[]; organizations: Organization[];
@@ -20,6 +20,22 @@ interface OrganizationListProps {
onAddOrganization?: () => void; onAddOrganization?: () => void;
} }
// Helper function to get status badge variant and icon
const getStatusBadge = (status: string | null) => {
switch (status) {
case "imported":
return { variant: "secondary" as const, label: "Not Mirrored", icon: null };
case "mirroring":
return { variant: "outline" as const, label: "Mirroring", icon: Clock };
case "mirrored":
return { variant: "default" as const, label: "Mirrored", icon: Check };
case "failed":
return { variant: "destructive" as const, label: "Failed", icon: AlertCircle };
default:
return { variant: "secondary" as const, label: "Unknown", icon: null };
}
};
export function OrganizationList({ export function OrganizationList({
organizations, organizations,
isLoading, isLoading,
@@ -93,29 +109,45 @@ export function OrganizationList({
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredOrganizations.map((org, index) => { {filteredOrganizations.map((org, index) => {
const isLoading = loadingOrgIds.has(org.id ?? ""); const isLoading = loadingOrgIds.has(org.id ?? "");
const statusBadge = getStatusBadge(org.status);
const StatusIcon = statusBadge.icon;
return ( return (
<Card key={index} className="overflow-hidden p-4"> <Card
<div className="flex items-center justify-between mb-2"> key={index}
<div className="flex items-center gap-2"> className={cn(
<Building2 className="h-5 w-5 text-muted-foreground" /> "overflow-hidden p-4 transition-all hover:shadow-md min-h-[160px]",
<a isLoading && "opacity-75"
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`} )}
className="font-medium hover:underline cursor-pointer" >
> <div className="flex items-start justify-between mb-3">
{org.name} <div className="flex-1">
</a> <div className="flex items-center gap-2 mb-1">
<Building2 className="h-5 w-5 text-muted-foreground" />
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer"
>
{org.name}
</a>
<span
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
org.membershipRole === "member"
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
}`}
>
{org.membershipRole}
</span>
</div>
</div> </div>
<span <Badge variant={statusBadge.variant} className="ml-2">
className={`text-xs px-2 py-1 rounded-full capitalize ${ {StatusIcon && <StatusIcon className={cn(
org.membershipRole === "member" "h-3 w-3",
? "bg-blue-100 text-blue-800" org.status === "mirroring" && "animate-pulse"
: "bg-purple-100 text-purple-800" )} />}
}`} {statusBadge.label}
> </Badge>
{org.membershipRole}
{/* needs to be updated */}
</span>
</div> </div>
<div className="text-sm text-muted-foreground mb-4"> <div className="text-sm text-muted-foreground mb-4">
@@ -125,58 +157,96 @@ export function OrganizationList({
{org.repositoryCount === 1 ? "repository" : "repositories"} {org.repositoryCount === 1 ? "repository" : "repositories"}
</span> </span>
</div> </div>
{(org.publicRepositoryCount !== undefined || {/* Always render this section to prevent layout shift */}
org.privateRepositoryCount !== undefined || <div className="flex gap-4 mt-2 text-xs min-h-[20px]">
org.forkRepositoryCount !== undefined) && ( {isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
<div className="flex gap-4 mt-2 text-xs"> <>
{org.publicRepositoryCount !== undefined && ( <Skeleton className="h-3 w-16" />
<span className="flex items-center gap-1"> <Skeleton className="h-3 w-16" />
<div className="h-2 w-2 rounded-full bg-green-500" /> </>
{org.publicRepositoryCount} public ) : (
</span> <>
)} {org.publicRepositoryCount !== undefined ? (
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 && ( <span className="flex items-center gap-1">
<span className="flex items-center gap-1"> <div className="h-2 w-2 rounded-full bg-green-500" />
<div className="h-2 w-2 rounded-full bg-orange-500" /> {org.publicRepositoryCount} public
{org.privateRepositoryCount} private </span>
</span> ) : null}
)} {org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 ? (
{org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 && ( <span className="flex items-center gap-1">
<span className="flex items-center gap-1"> <div className="h-2 w-2 rounded-full bg-orange-500" />
<div className="h-2 w-2 rounded-full bg-blue-500" /> {org.privateRepositoryCount} private
{org.forkRepositoryCount} fork{org.forkRepositoryCount !== 1 ? 's' : ''} </span>
</span> ) : null}
)} {org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 ? (
</div> <span className="flex items-center gap-1">
)} <div className="h-2 w-2 rounded-full bg-blue-500" />
{org.forkRepositoryCount} fork{org.forkRepositoryCount !== 1 ? 's' : ''}
</span>
) : null}
{/* Show a placeholder if no counts are available to maintain height */}
{org.publicRepositoryCount === undefined &&
org.privateRepositoryCount === undefined &&
org.forkRepositoryCount === undefined && (
<span className="invisible">Loading counts...</span>
)}
</>
)}
</div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center gap-2">
<Checkbox {org.status === "imported" && (
id={`include-${org.id}`} <Button
name={`include-${org.id}`} size="sm"
checked={org.status === "mirrored"} onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={ disabled={isLoading}
loadingOrgIds.has(org.id ?? "") || >
org.status === "mirrored" || {isLoading ? (
org.status === "mirroring" <>
} <RefreshCw className="h-3 w-3 animate-spin mr-2" />
onCheckedChange={async (checked) => { Starting...
if (checked && !org.isIncluded && org.id) { </>
onMirror({ orgId: org.id }); ) : (
} "Mirror"
}} )}
/> </Button>
<label )}
htmlFor={`include-${org.id}`}
className="ml-2 text-sm select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" {org.status === "mirroring" && (
> <Button size="sm" disabled variant="outline">
Enable mirroring <RefreshCw className="h-3 w-3 animate-spin mr-2" />
</label> Mirroring...
</Button>
{isLoading && ( )}
<RefreshCw className="opacity-50 h-4 w-4 animate-spin ml-4" />
{org.status === "mirrored" && (
<Button size="sm" disabled variant="secondary">
<Check className="h-3 w-3 mr-2" />
Mirrored
</Button>
)}
{org.status === "failed" && (
<Button
size="sm"
variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
Retrying...
</>
) : (
<>
<AlertCircle className="h-3 w-3 mr-2" />
Retry
</>
)}
</Button>
)} )}
</div> </div>
@@ -185,19 +255,12 @@ export function OrganizationList({
href={`https://github.com/${org.name}`} href={`https://github.com/${org.name}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="View on GitHub"
> >
<SiGithub className="h-4 w-4" /> <SiGithub className="h-4 w-4" />
</a> </a>
</Button> </Button>
</div> </div>
{/* dont know if this looks good. maybe revised */}
<div className="flex items-center gap-2 justify-end mt-4">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(org.status)}`}
/>
<span className="text-sm capitalize">{org.status}</span>
</div>
</Card> </Card>
); );
})} })}

View File

@@ -18,7 +18,7 @@ import {
SelectValue, SelectValue,
} from "../ui/select"; } from "../ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal } from "lucide-react"; import { Search, RefreshCw, FlipHorizontal, RotateCcw, X } from "lucide-react";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror"; import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import { useSSE } from "@/hooks/useSEE"; import { useSSE } from "@/hooks/useSEE";
import { useFilterParams } from "@/hooks/useFilterParams"; import { useFilterParams } from "@/hooks/useFilterParams";
@@ -46,6 +46,7 @@ export default function Repository() {
owner: "", owner: "",
}); });
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false); const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const [selectedRepoIds, setSelectedRepoIds] = useState<Set<string>>(new Set());
// Read organization filter from URL when component mounts // Read organization filter from URL when component mounts
useEffect(() => { useEffect(() => {
@@ -254,6 +255,143 @@ export default function Repository() {
} }
}; };
// 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<MirrorRepoResponse>("/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 => repo.status === "mirrored" || repo.status === "synced"
);
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<SyncRepoResponse>("/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<RetryRepoResponse>("/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 handleSyncRepo = async ({ repoId }: { repoId: string }) => { const handleSyncRepo = async ({ repoId }: { repoId: string }) => {
try { try {
if (!user || !user.id) { if (!user || !user.id) {
@@ -392,6 +530,35 @@ export default function Repository() {
) )
).sort(); ).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 statuses = new Set(selectedRepos.map(repo => repo.status));
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');
}
return actions;
};
const availableActions = getAvailableActions();
return ( return (
<div className="flex flex-col gap-y-8"> <div className="flex flex-col gap-y-8">
{/* Combine search and actions into a single flex row */} {/* Combine search and actions into a single flex row */}
@@ -459,14 +626,69 @@ export default function Repository() {
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
<Button {/* Context-aware action buttons */}
variant="default" {selectedRepoIds.size === 0 ? (
onClick={handleMirrorAllRepos} <Button
disabled={isInitialLoading || loadingRepoIds.size > 0} variant="default"
> onClick={handleMirrorAllRepos}
<FlipHorizontal className="h-4 w-4 mr-2" /> disabled={isInitialLoading || loadingRepoIds.size > 0}
Mirror All >
</Button> <FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
) : (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setSelectedRepoIds(new Set())}
>
<X className="h-4 w-4" />
</Button>
</div>
{availableActions.includes('mirror') && (
<Button
variant="default"
size="sm"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
</div>
)}
</div> </div>
{!isGitHubConfigured ? ( {!isGitHubConfigured ? (
@@ -497,6 +719,8 @@ export default function Repository() {
onSync={handleSyncRepo} onSync={handleSyncRepo}
onRetry={handleRetryRepoAction} onRetry={handleRetryRepoAction}
loadingRepoIds={loadingRepoIds} loadingRepoIds={loadingRepoIds}
selectedRepoIds={selectedRepoIds}
onSelectionChange={setSelectedRepoIds}
/> />
)} )}

View File

@@ -9,6 +9,13 @@ import { formatDate, getStatusColor } from "@/lib/utils";
import type { FilterParams } from "@/types/filter"; import type { FilterParams } from "@/types/filter";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { Checkbox } from "@/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface RepositoryTableProps { interface RepositoryTableProps {
repositories: Repository[]; repositories: Repository[];
@@ -20,6 +27,8 @@ interface RepositoryTableProps {
onSync: ({ repoId }: { repoId: string }) => Promise<void>; onSync: ({ repoId }: { repoId: string }) => Promise<void>;
onRetry: ({ repoId }: { repoId: string }) => Promise<void>; onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
loadingRepoIds: Set<string>; loadingRepoIds: Set<string>;
selectedRepoIds: Set<string>;
onSelectionChange: (selectedIds: Set<string>) => void;
} }
export default function RepositoryTable({ export default function RepositoryTable({
@@ -32,6 +41,8 @@ export default function RepositoryTable({
onSync, onSync,
onRetry, onRetry,
loadingRepoIds, loadingRepoIds,
selectedRepoIds,
onSelectionChange,
}: RepositoryTableProps) { }: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null); const tableParentRef = useRef<HTMLDivElement>(null);
const { giteaConfig } = useGiteaConfig(); const { giteaConfig } = useGiteaConfig();
@@ -105,9 +116,36 @@ export default function RepositoryTable({
overscan: 5, 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;
return isLoading ? ( return isLoading ? (
<div className="border rounded-md"> <div className="border rounded-md">
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50"> <div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]"> <div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository Repository
</div> </div>
@@ -132,6 +170,9 @@ export default function RepositoryTable({
key={i} key={i}
className="h-[65px] flex items-center justify-between border-b bg-transparent" className="h-[65px] flex items-center justify-between border-b bg-transparent"
> >
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]"> <div className="h-full p-3 text-sm font-medium flex-[2.5]">
<Skeleton className="h-full w-full" /> <Skeleton className="h-full w-full" />
</div> </div>
@@ -187,6 +228,14 @@ export default function RepositoryTable({
<div className="flex flex-col border rounded-md"> <div className="flex flex-col border rounded-md">
{/* table header */} {/* table header */}
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50"> <div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
onCheckedChange={handleSelectAll}
aria-label="Select all repositories"
/>
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]"> <div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository Repository
</div> </div>
@@ -235,6 +284,15 @@ export default function RepositoryTable({
data-index={virtualRow.index} 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 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
> >
{/* Checkbox */}
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
aria-label={`Select ${repo.name}`}
/>
</div>
{/* Repository */} {/* Repository */}
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]"> <div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" /> <GitFork className="h-4 w-4 text-muted-foreground" />
@@ -277,12 +335,26 @@ export default function RepositoryTable({
{/* Status */} {/* Status */}
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]"> <div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
<div {repo.status === "failed" && repo.errorMessage ? (
className={`h-2 w-2 rounded-full ${getStatusColor( <TooltipProvider>
repo.status <Tooltip>
)}`} <TooltipTrigger asChild>
/> <div className="flex items-center gap-x-2 cursor-help">
<span className="text-sm capitalize">{repo.status}</span> <div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-sm">{repo.errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<>
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize">{repo.status}</span>
</>
)}
</div> </div>
{/* Actions */} {/* Actions */}