571 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|