diff --git a/src/components/repositories/Repository.tsx b/src/components/repositories/Repository.tsx index 81d1ddd..b70a5b9 100644 --- a/src/components/repositories/Repository.tsx +++ b/src/components/repositories/Repository.tsx @@ -18,7 +18,7 @@ import { SelectValue, } from "../ui/select"; 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 { useSSE } from "@/hooks/useSEE"; import { useFilterParams } from "@/hooks/useFilterParams"; @@ -46,6 +46,7 @@ export default function Repository() { owner: "", }); const [isDialogOpen, setIsDialogOpen] = useState(false); + const [selectedRepoIds, setSelectedRepoIds] = useState>(new Set()); // Read organization filter from URL when component mounts 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("/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("/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 handleSyncRepo = async ({ repoId }: { repoId: string }) => { try { if (!user || !user.id) { @@ -392,6 +530,35 @@ export default function Repository() { ) ).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 (
{/* Combine search and actions into a single flex row */} @@ -459,14 +626,69 @@ export default function Repository() { - + {/* Context-aware action buttons */} + {selectedRepoIds.size === 0 ? ( + + ) : ( +
+
+ + {selectedRepoIds.size} selected + + +
+ + {availableActions.includes('mirror') && ( + + )} + + {availableActions.includes('sync') && ( + + )} + + {availableActions.includes('retry') && ( + + )} +
+ )}
{!isGitHubConfigured ? ( @@ -497,6 +719,8 @@ export default function Repository() { onSync={handleSyncRepo} onRetry={handleRetryRepoAction} loadingRepoIds={loadingRepoIds} + selectedRepoIds={selectedRepoIds} + onSelectionChange={setSelectedRepoIds} /> )} diff --git a/src/components/repositories/RepositoryTable.tsx b/src/components/repositories/RepositoryTable.tsx index ca7aeec..b79091d 100644 --- a/src/components/repositories/RepositoryTable.tsx +++ b/src/components/repositories/RepositoryTable.tsx @@ -9,6 +9,13 @@ import { formatDate, 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"; interface RepositoryTableProps { repositories: Repository[]; @@ -20,6 +27,8 @@ interface RepositoryTableProps { onSync: ({ repoId }: { repoId: string }) => Promise; onRetry: ({ repoId }: { repoId: string }) => Promise; loadingRepoIds: Set; + selectedRepoIds: Set; + onSelectionChange: (selectedIds: Set) => void; } export default function RepositoryTable({ @@ -32,6 +41,8 @@ export default function RepositoryTable({ onSync, onRetry, loadingRepoIds, + selectedRepoIds, + onSelectionChange, }: RepositoryTableProps) { const tableParentRef = useRef(null); const { giteaConfig } = useGiteaConfig(); @@ -105,9 +116,36 @@ export default function RepositoryTable({ 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 ? (
+
+ +
Repository
@@ -132,6 +170,9 @@ export default function RepositoryTable({ key={i} className="h-[65px] flex items-center justify-between border-b bg-transparent" > +
+ +
@@ -187,6 +228,14 @@ export default function RepositoryTable({
{/* table header */}
+
+ +
Repository
@@ -235,6 +284,15 @@ export default function RepositoryTable({ 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 > + {/* Checkbox */} +
+ repo.id && handleSelectRepo(repo.id, !!checked)} + aria-label={`Select ${repo.name}`} + /> +
+ {/* Repository */}
@@ -277,12 +335,26 @@ export default function RepositoryTable({ {/* Status */}
-
- {repo.status} + {repo.status === "failed" && repo.errorMessage ? ( + + + +
+
+ {repo.status} +
+ + +

{repo.errorMessage}

+
+ + + ) : ( + <> +
+ {repo.status} + + )}
{/* Actions */}