mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-10 21:46:45 +03:00
feat: add bulk actions for repository management with selection support
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user