gitdataai/src/page/workspace/repo/pulls.tsx

185 lines
8.0 KiB
TypeScript

import { useState } from "react";
import { Link, useParams, useSearchParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { client, type PullRequestResponse } from "@/client";
import {
Circle,
CircleDot,
GitMerge,
GitPullRequest,
Search,
Plus,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { workspaceColor, workspaceInitial } from "@/components/shell/shared";
import PaginationBar from "./pagination";
function formatDate(date: string) {
const now = new Date();
const d = new Date(date);
const diff = now.getTime() - d.getTime();
const days = Math.floor(diff / 86400000);
if (days < 1) return "today";
if (days === 1) return "1 day ago";
if (days < 30) return `${days} days ago`;
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
return `${Math.floor(days / 365)}y ago`;
}
function AuthorAvatar({ author }: { author: { username: string; avatar_url?: string | null; display_name?: string | null } }) {
const name = author.display_name ?? author.username;
return (
<span
className={cn(
"grid size-5 place-items-center overflow-hidden rounded-full bg-gradient-to-br text-[10px] font-bold text-white",
workspaceColor(name),
)}
>
{author.avatar_url ? (
<img alt={name + " 的头像"} className="size-full object-cover" src={author.avatar_url} />
) : (
workspaceInitial(name)
)}
</span>
);
}
function LabelBadge({ label }: { label: { name: string; color: string } }) {
return (
<span
className="inline-flex h-[18px] items-center rounded px-1.5 text-[11px] font-medium leading-none whitespace-nowrap"
style={{ backgroundColor: label.color + "18", color: label.color }}
>
{label.name}
</span>
);
}
function PullStateIcon({ pr }: { pr: PullRequestResponse }) {
if (pr.merged_at) {
return <GitMerge className="size-[14px] text-purple-600 shrink-0 mt-0.5" />;
}
if (pr.state === "closed") {
return <CircleDot className="size-[14px] text-destructive shrink-0 mt-0.5" />;
}
return <Circle className="size-[14px] text-success shrink-0 mt-0.5" />;
}
function PullRow({ pr, projectName, repoName }: { pr: PullRequestResponse; projectName: string; repoName: string }) {
return (
<Link
className="group flex items-start gap-3 border-b border-border px-4 py-2.5 last:border-b-0 transition-colors hover:bg-muted/40"
to={`/${projectName}/repo/${repoName}/pulls/${pr.number}`}
>
<PullStateIcon pr={pr} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="font-heading text-[13px] font-semibold text-foreground leading-tight">{pr.title}</span>
<span className="font-mono text-[11px] text-muted-foreground/60"># {pr.number}</span>
{pr.draft && (
<span className="rounded-md border border-border bg-muted/30 px-1.5 py-0.5 text-[11px] font-medium leading-none text-muted-foreground">
Draft
</span>
)}
{pr.labels.map((label) => <LabelBadge key={label.id} label={label} />)}
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap">
<span className="flex items-center gap-1">
<AuthorAvatar author={pr.author} />
{pr.author.display_name ?? pr.author.username}
</span>
<span>opened {formatDate(pr.created_at)}</span>
<span className="font-mono text-[10px] text-muted-foreground/60">
{pr.source_branch} {pr.target_branch}
</span>
</div>
</div>
</Link>
);
}
const LIMIT = 20;
export default function PullsTab() {
const { projectName = "", repoName = "" } = useParams();
const [stateFilter, setStateFilter] = useState<string>("open");
const [search, setSearch] = useState("");
const [pullsSearchParams, setPullsSearchParams] = useSearchParams();
const offset = Number(pullsSearchParams.get("offset") ?? "0");
const setOffset = (n: number) => {
const next = new URLSearchParams(pullsSearchParams);
if (n <= 0) next.delete("offset"); else next.set("offset", String(n));
setPullsSearchParams(next, { replace: true });
};
const { data: prs = [], isLoading } = useQuery({
queryKey: ["repo", projectName, repoName, "pulls", stateFilter, offset],
queryFn: async () => {
const res = await client.pullRequestListPrs(projectName, repoName, { state: stateFilter === "all" ? undefined : stateFilter, offset, limit: LIMIT });
return res.data;
},
enabled: Boolean(projectName) && Boolean(repoName),
retry: false,
});
const filteredPrs = search
? prs.filter((pr) => pr.title.toLowerCase().includes(search.toLowerCase()) || String(pr.number).includes(search))
: prs;
const openCount = prs.filter((pr) => pr.state === "open" && !pr.merged_at).length;
const closedCount = prs.filter((pr) => pr.state === "closed" || pr.merged_at).length;
return (
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card shadow-sm">
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2">
<nav className="flex items-center">
{[
{ key: "open", icon: <Circle className="size-3 text-success" />, count: openCount },
{ key: "closed", icon: <CircleDot className="size-3 text-destructive" />, count: closedCount },
{ key: "all", icon: <GitPullRequest className="size-3" />, count: prs.length },
].map((tab) => (
<button
className={cn("flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium transition-colors", stateFilter === tab.key ? "bg-card text-foreground shadow-sm ring-1 ring-border" : "text-muted-foreground hover:bg-background/60 hover:text-foreground")}
key={tab.key}
onClick={() => { setStateFilter(tab.key); setOffset(0); }}
>
{tab.icon}
{tab.key === "all" ? "All" : tab.key.charAt(0).toUpperCase() + tab.key.slice(1)}
<span className="font-mono text-[10px] text-muted-foreground">{tab.count}</span>
</button>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<Link className="inline-flex h-8 items-center rounded-lg border border-border bg-card px-3 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground" to={`/${projectName}/repo/${repoName}/pulls/new`}>
<Plus className="size-3 mr-1" />New PR
</Link>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input autoComplete="off" className="h-8 w-52 rounded-lg border-border bg-background pl-7 text-[12px]" placeholder="Filter PRs…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
</div>
</div>
{isLoading ? (
<div className="px-4 py-2">
{Array.from({ length: 3 }).map((_, i) => <div className="h-6 animate-pulse rounded bg-muted/50 my-1.5" key={i} />)}
</div>
) : filteredPrs.length > 0 ? (
<>
<div>{filteredPrs.map((pr) => <PullRow key={pr.number} pr={pr} projectName={projectName} repoName={repoName} />)}</div>
<PaginationBar offset={offset} limit={LIMIT} hasMore={prs.length >= LIMIT} onPrev={() => setOffset(Math.max(0, offset - LIMIT))} onNext={() => setOffset(offset + LIMIT)} />
</>
) : (
<div className="py-12 text-center">
<GitPullRequest className="mx-auto size-5 text-muted-foreground/20" />
<p className="mt-3 text-[13px] text-muted-foreground">
{stateFilter === "open" ? "No open pull requests" : stateFilter === "closed" ? "No closed pull requests" : "No pull requests yet"}
</p>
</div>
)}
</div>
</div>
);
}