Pipeline/frontend/src/components/git/ForgejoRepositoriesTable.tsx

571 lines
17 KiB
TypeScript

"use client";
import { useState } from "react";
import {
type ColumnDef,
type Table as TableType,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
AlertCircle,
Archive,
CheckCircle2,
CircleDot,
Eye,
GitBranch,
GitCommitHorizontal,
GitFork,
KeyRound,
Loader2,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { DataTable } from "@/components/tables/DataTable";
import { cn } from "@/lib/utils";
import type {
ForgejoRepository,
ForgejoRepositoryValidationResponse,
} from "@/lib/api-forgejo";
const repositoryLabel = (repo: ForgejoRepository) =>
repo.display_name || `${repo.owner}/${repo.repo}`;
const repositoryTone = (repo: ForgejoRepository) => {
if (repo.last_sync_error) return "danger";
if (!repo.active || repo.is_archived) return "muted";
if (!repo.has_webhook_secret || !repo.last_sync_at) return "warning";
return "success";
};
const toneClasses = {
success: {
rail: "border-l-[color:var(--success)]",
row: "bg-[color:rgba(52,211,153,0.025)] hover:bg-[color:rgba(52,211,153,0.07)]",
icon: "border-[color:rgba(52,211,153,0.28)] bg-[color:rgba(52,211,153,0.13)] text-[color:var(--success)]",
dot: "bg-[color:var(--success)]",
},
warning: {
rail: "border-l-[color:var(--warning)]",
row: "bg-[color:rgba(251,191,36,0.025)] hover:bg-[color:rgba(251,191,36,0.075)]",
icon: "border-[color:rgba(251,191,36,0.3)] bg-[color:rgba(251,191,36,0.13)] text-[color:var(--warning)]",
dot: "bg-[color:var(--warning)]",
},
danger: {
rail: "border-l-[color:var(--danger)]",
row: "bg-[color:rgba(248,113,113,0.028)] hover:bg-[color:rgba(248,113,113,0.08)]",
icon: "border-[color:rgba(248,113,113,0.3)] bg-[color:rgba(248,113,113,0.13)] text-[color:var(--danger)]",
dot: "bg-[color:var(--danger)]",
},
muted: {
rail: "border-l-[color:var(--border-strong)]",
row: "hover:bg-[color:var(--surface-muted)]",
icon: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
dot: "bg-[color:var(--text-quiet)]",
},
} as const;
const formatSyncTime = (value: string | null) => {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return {
date: date.toLocaleDateString(),
time: date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
};
};
type RepositorySyncResult = {
created: number;
updated: number;
open: number;
closed: number;
total: number;
};
interface RepositoriesTableProps {
repositories: ForgejoRepository[];
isLoading: boolean;
onEdit?: (repository: ForgejoRepository) => void;
onDelete?: (repository: ForgejoRepository) => void;
onViewDetails?: (repository: ForgejoRepository) => void;
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
onValidate?: (
repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>;
}
export function ForgejoRepositoriesTable({
repositories,
isLoading,
onEdit,
onDelete,
onViewDetails,
onSync,
onValidate,
}: RepositoriesTableProps) {
// onEdit available for future use
const _ = onEdit;
const table = useReactTable({
data: repositories,
columns: columns(onSync, onValidate, onViewDetails),
getCoreRowModel: getCoreRowModel(),
});
return (
<DataTable
table={table}
isLoading={isLoading}
emptyState={{
icon: <GitBranch className="h-12 w-12" />,
title: "No Git Project repositories yet",
description:
"Add repositories so Pipeline can sync issues into Git Projects.",
actionHref: "/git-projects/repositories/new",
actionLabel: "Add repository",
}}
rowActions={{
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
onDelete: onDelete ?? undefined,
cellClassName: "px-3 py-3 align-middle md:px-5",
}}
tableClassName="min-w-[900px] w-full text-left text-sm"
headerClassName="bg-[linear-gradient(90deg,rgba(96,165,250,0.16),rgba(52,211,153,0.1),rgba(251,191,36,0.08))] text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]"
headerCellClassName="px-3 py-3 md:px-5"
cellClassName="px-3 py-4 align-middle md:px-5"
rowClassName={(row) => {
const tone = repositoryTone(row.original);
return cn(
"border-l-4 transition-colors",
toneClasses[tone].rail,
toneClasses[tone].row,
);
}}
/>
);
}
const columns = (
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>,
onValidate?: (
repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>,
onViewDetails?: (repository: ForgejoRepository) => void,
): ColumnDef<ForgejoRepository>[] => [
{
accessorKey: "displayName",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(false)}
className="h-auto px-0 py-0 hover:bg-transparent hover:text-[color:var(--accent)]"
>
Repository
{column.getIsSorted() === "asc" && "↑"}
{column.getIsSorted() === "desc" && "↓"}
</Button>
);
},
cell: ({ row }) => {
const repo = row.original;
const tone = repositoryTone(repo);
return (
<div className="flex min-w-[280px] items-start gap-3">
<div
className={cn(
"mt-0.5 rounded-xl border p-2 shadow-[0_0_22px_rgba(96,165,250,0.08)]",
toneClasses[tone].icon,
)}
>
<GitBranch className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate font-semibold text-strong">
{repositoryLabel(repo)}
</span>
{repo.default_branch ? (
<span className="inline-flex items-center gap-1 rounded-full border border-[color:rgba(96,165,250,0.26)] bg-[color:rgba(96,165,250,0.1)] px-2 py-0.5 font-mono text-[11px] text-[color:var(--accent-strong)]">
<GitCommitHorizontal className="h-3 w-3" />
{repo.default_branch}
</span>
) : null}
</div>
<span className="mt-1 flex min-w-0 items-center gap-1 truncate font-mono text-xs text-muted">
<GitFork className="h-3 w-3 shrink-0" />
<span className="truncate">
{repo.owner}/{repo.repo}
</span>
{repo.connection?.name ? (
<span className="truncate text-[color:var(--text-quiet)]">
/ {repo.connection.name}
</span>
) : null}
</span>
{repo.description ? (
<span className="mt-1 block max-w-[360px] truncate text-xs text-muted">
{repo.description}
</span>
) : null}
{repo.topics.length ? (
<div className="mt-2 flex max-w-[360px] flex-wrap gap-1.5">
{repo.topics.slice(0, 3).map((topic) => (
<Badge
key={topic}
variant="accent"
className="h-5 rounded-full px-2 text-[11px] normal-case"
>
{topic}
</Badge>
))}
{repo.topics.length > 3 ? (
<Badge
variant="outline"
className="h-5 rounded-full px-2 text-[11px]"
>
+{repo.topics.length - 3}
</Badge>
) : null}
</div>
) : null}
</div>
</div>
);
},
},
{
accessorKey: "connection",
header: "Connection",
cell: ({ row }) => {
const connection = row.original.connection;
return (
<div className="min-w-[180px]">
<span className="block truncate font-medium text-sm text-strong">
{connection?.name}
</span>
<span className="mt-1 inline-flex max-w-[240px] items-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-2 py-0.5 font-mono text-[11px] text-muted">
{connection?.base_url}
</span>
</div>
);
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const repo = row.original;
const isActive = repo.active;
return (
<div className="flex max-w-[230px] flex-wrap gap-1.5">
<Badge variant={isActive ? "success" : "outline"} className="gap-1">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
isActive
? "bg-[color:var(--success)]"
: "bg-[color:var(--text-quiet)]",
)}
/>
{isActive ? "Active" : "Inactive"}
</Badge>
{repo.is_archived ? (
<Badge variant="warning" className="gap-1">
<Archive className="h-3 w-3" />
Archived
</Badge>
) : null}
{repo.has_webhook_secret ? (
<Badge variant="success" className="gap-1">
<KeyRound className="h-3 w-3" />
Webhook
</Badge>
) : (
<Badge variant="outline" className="gap-1">
<KeyRound className="h-3 w-3" />
No secret
</Badge>
)}
{repo.last_sync_error ? (
<Badge variant="danger" className="gap-1">
<AlertCircle className="h-3 w-3" />
Sync error
</Badge>
) : repo.last_sync_at ? (
<Badge variant="accent" className="gap-1">
<ShieldCheck className="h-3 w-3" />
Synced
</Badge>
) : (
<Badge variant="warning" className="gap-1">
<CircleDot className="h-3 w-3" />
New
</Badge>
)}
</div>
);
},
},
{
accessorKey: "openIssues",
header: "Issues",
cell: ({ row }) => (
<div
className={cn(
"inline-flex min-w-[94px] flex-col rounded-xl border px-3 py-2",
row.original.open_issues_count > 0
? "border-[color:rgba(251,191,36,0.28)] bg-[color:rgba(251,191,36,0.1)]"
: "border-[color:rgba(52,211,153,0.24)] bg-[color:rgba(52,211,153,0.08)]",
)}
>
<span className="text-lg font-semibold leading-none text-strong">
{row.original.open_issues_count}
</span>
<span className="mt-1 text-[11px] uppercase tracking-wide text-muted">
open
</span>
</div>
),
},
{
accessorKey: "lastSync",
header: "Last Sync",
cell: ({ row }) => {
const lastSyncAt = row.original.last_sync_at;
const lastSyncError = row.original.last_sync_error;
const tone = repositoryTone(row.original);
const syncTime = formatSyncTime(lastSyncAt);
if (!syncTime) {
return (
<div className="flex items-center gap-2 text-sm text-muted">
<span
className={cn("h-2 w-2 rounded-full", toneClasses[tone].dot)}
/>
Never
</div>
);
}
return (
<div className="flex min-w-[150px] items-start gap-2">
<span
className={cn("mt-1.5 h-2 w-2 rounded-full", toneClasses[tone].dot)}
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-strong">
{syncTime.date}
</span>
<span className="text-xs text-muted">{syncTime.time}</span>
{lastSyncError && (
<span className="max-w-[220px] truncate text-xs text-[color:var(--danger)]">
Error: {lastSyncError.substring(0, 50)}...
</span>
)}
</div>
</div>
);
},
},
{
id: "actions",
cell: ({ row }) => (
<ActionsCell
repository={row.original}
onSync={onSync}
onValidate={onValidate}
onViewDetails={onViewDetails}
/>
),
},
];
function ActionsCell({
repository,
onSync,
onValidate,
onViewDetails,
}: {
repository: ForgejoRepository;
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
onValidate?: (
repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>;
onViewDetails?: (repository: ForgejoRepository) => void;
}) {
const [isSyncLoading, setIsSyncLoading] = useState(false);
const [isValidateLoading, setIsValidateLoading] = useState(false);
const [syncResult, setSyncResult] = useState<{
created: number;
updated: number;
open: number;
closed: number;
total: number;
} | null>(null);
const [validateResult, setValidateResult] = useState<{
ok: boolean;
repo_exists?: boolean;
error_message?: string;
} | null>(null);
const handleSync = async () => {
if (!onSync) return;
setIsSyncLoading(true);
try {
const result = await onSync(repository);
setSyncResult(result);
} finally {
setIsSyncLoading(false);
}
};
const handleValidate = async () => {
if (!onValidate) return;
setIsValidateLoading(true);
try {
const result = await onValidate(repository);
setValidateResult({
ok: result.status.ok,
repo_exists: result.repo_exists ?? undefined,
error_message: result.status.error_message ?? undefined,
});
} finally {
setIsValidateLoading(false);
}
};
return (
<div className="flex items-center gap-2">
{onSync && (
<Button
variant="ghost"
onClick={handleSync}
disabled={isSyncLoading}
className="h-8 w-8 p-0"
title="Sync issues"
aria-label={`Sync issues for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
>
{isSyncLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : syncResult ? (
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
)}
{onValidate && (
<Button
variant="ghost"
onClick={handleValidate}
disabled={isValidateLoading}
className="h-8 w-8 p-0"
title="Validate repository"
aria-label={`Validate ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
>
{isValidateLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validateResult?.ok ? (
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</Button>
)}
{onViewDetails && (
<Button
variant="ghost"
onClick={() => onViewDetails(repository)}
className="h-8 w-8 p-0"
title="Repository details"
aria-label={`View details for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
>
<Eye className="h-4 w-4" />
</Button>
)}
</div>
);
}
// Filter component
export function RepositoriesTableFilter({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<input
type="text"
placeholder="Filter repositories…"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
className="h-9 w-[150px] rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1 text-sm text-strong focus:border-[color:var(--accent)] focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)] lg:w-[250px]"
/>
);
}
// Column toggle component
export function RepositoriesTableToggle({
table,
}: {
table: TableType<ForgejoRepository>;
}) {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Show:</span>
<Button
variant="ghost"
size="sm"
onClick={() => table.toggleAllColumnsVisible()}
className="h-8 px-2 py-1 text-xs"
>
All
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => table.toggleAllColumnsVisible(false)}
className="h-8 px-2 py-1 text-xs"
>
None
</Button>
</div>
<div className="flex gap-1">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<Button
key={column.id}
variant="ghost"
size="sm"
onClick={() => column.toggleVisibility(!column.getIsVisible())}
className={cn(
"h-8 px-2 py-1 text-xs",
column.getIsVisible()
? "bg-[color:var(--surface-strong)] text-strong"
: "text-muted",
)}
>
{column.id}
</Button>
);
})}
</div>
</div>
);
}