feat(repo): add repository components

This commit is contained in:
zhenyi 2026-05-31 13:12:36 +08:00
parent 1ef37786b1
commit 980cd54b66
8 changed files with 1091 additions and 0 deletions

View File

@ -0,0 +1,202 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/client";
import { GitBranch, Search, Plus, Trash2, Pencil } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export default function BranchesPanel({ workspace, repo }: { workspace: string; repo: string }) {
const { data: repoData } = useQuery({
queryKey: ["repo", workspace, repo],
queryFn: async () => {
const res = await client.gitGetRepo(workspace, repo);
return res.data;
},
enabled: Boolean(workspace) && Boolean(repo),
retry: false,
});
const defaultBranch = repoData?.default_branch ?? "main";
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [newBranchSource, setNewBranchSource] = useState(defaultBranch);
const [renaming, setRenaming] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const { data: branches = [], isLoading } = useQuery({
queryKey: ["repo", workspace, repo, "branches"],
queryFn: async () => {
const res = await client.gitListBranches(workspace, repo);
return res.data.branches;
},
enabled: Boolean(workspace) && Boolean(repo),
retry: false,
});
const createBranch = useMutation({
mutationFn: async () => { await client.gitForkBranch(workspace, repo, { name: newBranchName, oid: newBranchSource } as any); },
onSuccess: () => { setShowCreate(false); setNewBranchName(""); qc.invalidateQueries({ queryKey: ["repo", workspace, repo, "branches"] }); },
});
const deleteBranch = useMutation({
mutationFn: async (name: string) => { await client.gitDeleteBranch(workspace, repo, name); },
onSuccess: () => qc.invalidateQueries({ queryKey: ["repo", workspace, repo, "branches"] }),
});
const renameBranch = useMutation({
mutationFn: async ({ oldName, newName }: { oldName: string; newName: string }) => { await (client as any).gitRenameBranch(workspace, repo, oldName, { new_branch: newName }); },
onSuccess: () => { setRenaming(null); setRenameValue(""); qc.invalidateQueries({ queryKey: ["repo", workspace, repo, "branches"] }); },
});
const filtered = search ? branches.filter((b: any) => b.name.toLowerCase().includes(search.toLowerCase())) : branches;
const defaultBranches = filtered.filter((b: any) => b.name === defaultBranch);
const otherBranches = filtered.filter((b: any) => b.name !== defaultBranch);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-[300px]">
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input className="h-7 w-full rounded-sm border-border bg-card pl-7 text-[12px]" placeholder="Find a branch..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Button className="h-7 px-2 text-[12px] ml-auto" onClick={() => setShowCreate(!showCreate)}>
<Plus className="size-3 mr-1" /> New branch
</Button>
</div>
{showCreate && (
<div className="flex items-center gap-2 rounded-md border border-border p-3">
<Input className="h-8 text-[13px] flex-1" placeholder="Branch name" value={newBranchName} onChange={(e) => setNewBranchName(e.target.value)} />
<Input className="h-8 w-36 text-[12px]" placeholder="Source (default: HEAD)" value={newBranchSource} onChange={(e) => setNewBranchSource(e.target.value)} />
<Button className="h-8 px-3 text-[12px]" disabled={!newBranchName.trim() || createBranch.isPending} onClick={() => createBranch.mutate()}>Create</Button>
<Button className="h-8 px-3 text-[12px]" onClick={() => setShowCreate(false)} variant="outline">Cancel</Button>
</div>
)}
<div className="rounded-md border border-border overflow-hidden">
{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>
) : filtered.length > 0 ? (
<div>
{defaultBranches.map((b: any) => <BranchRow key={b.name} branch={b} isDefault onDelete={deleteBranch} onRename={renameBranch} renaming={renaming} setRenaming={setRenaming} renameValue={renameValue} setRenameValue={setRenameValue} />)}
{otherBranches.length > 0 && defaultBranches.length > 0 && (
<div className="border-b border-border bg-muted/30 px-3 py-1">
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">Other branches</span>
</div>
)}
{otherBranches.map((b: any) => <BranchRow key={b.name} branch={b} onDelete={deleteBranch} onRename={renameBranch} renaming={renaming} setRenaming={setRenaming} renameValue={renameValue} setRenameValue={setRenameValue} />)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="grid size-14 place-items-center rounded-2xl bg-muted/40">
<GitBranch className="size-6 text-muted-foreground/40" />
</div>
<h3 className="mt-4 font-heading text-sm font-semibold text-foreground">No branches yet</h3>
<p className="mt-1.5 max-w-[280px] text-[12px] leading-relaxed text-muted-foreground">
{search ? `No branches matching "${search}"` : "Create a branch to start working on your project."}
</p>
{!search && (
<Button className="mt-4 h-8 px-3 text-[12px]" onClick={() => setShowCreate(true)}>
<Plus className="size-3 mr-1" /> Create first branch
</Button>
)}
</div>
)}
</div>
{/* Branch comparison */}
<BranchComparison workspace={workspace} repo={repo} branches={branches} />
</div>
);
}
function BranchComparison({ workspace, repo, branches }: { workspace: string; repo: string; branches: { name: string }[] }) {
const [oldBranch, setOldBranch] = useState("");
const [newBranch, setNewBranch] = useState("");
const { data: diffResult } = useQuery({
queryKey: ["repo", workspace, repo, "branch-diff", oldBranch, newBranch],
queryFn: async () => {
const res = await client.gitDiffBranches(workspace, repo, { old_branch: oldBranch, new_branch: newBranch });
return res.data;
},
enabled: Boolean(oldBranch && newBranch && oldBranch !== newBranch),
retry: false,
});
return (
<div className="rounded-md border border-border overflow-hidden">
<div className="bg-muted/50 px-3 py-2 border-b border-border">
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">Compare branches</span>
</div>
<div className="p-3 space-y-3">
<div className="flex items-center gap-2">
<select className="h-8 flex-1 rounded-sm border border-border bg-card px-2 text-[12px] font-mono text-foreground" value={oldBranch} onChange={(e) => setOldBranch(e.target.value)}>
<option value="">Select old branch...</option>
{branches.map((b: any) => <option key={b.name} value={b.name}>{b.name}</option>)}
</select>
<span className="text-muted-foreground text-[11px]"></span>
<select className="h-8 flex-1 rounded-sm border border-border bg-card px-2 text-[12px] font-mono text-foreground" value={newBranch} onChange={(e) => setNewBranch(e.target.value)}>
<option value="">Select new branch...</option>
{branches.map((b: any) => <option key={b.name} value={b.name}>{b.name}</option>)}
</select>
</div>
{diffResult?.stats && (
<div className="flex items-center gap-3 text-[12px] font-mono text-muted-foreground">
<span>{diffResult.stats.files_changed} files</span>
<span className="text-success">+{diffResult.stats.insertions}</span>
<span className="text-destructive">-{diffResult.stats.deletions}</span>
</div>
)}
</div>
</div>
);
}
function BranchRow({ branch, isDefault, onDelete, onRename, renaming, setRenaming, renameValue, setRenameValue }: {
branch: { name: string; oid: string; is_current?: boolean; is_head?: boolean };
isDefault?: boolean;
onDelete: any;
onRename: any;
renaming: string | null;
setRenaming: any;
renameValue: string;
setRenameValue: any;
}) {
const isEditing = renaming === branch.name;
return (
<div className="flex items-center gap-3 border-b border-border px-4 py-2.5 last:border-b-0 transition-colors hover:bg-muted/40">
<GitBranch className={cn("size-4 shrink-0", isDefault ? "text-primary" : "text-muted-foreground")} />
{isEditing ? (
<div className="flex items-center gap-2 flex-1">
<Input className="h-7 text-[13px] flex-1" value={renameValue} onChange={(e) => setRenameValue(e.target.value)} autoFocus />
<Button className="h-7 px-2 text-[11px]" disabled={!renameValue.trim()} onClick={() => onRename.mutate({ oldName: branch.name, newName: renameValue.trim() })}>Save</Button>
<Button className="h-7 px-2 text-[11px]" onClick={() => setRenaming(null)} variant="outline">Cancel</Button>
</div>
) : (
<>
<span className="font-heading text-[13px] font-semibold text-foreground">{branch.name}</span>
{isDefault && <span className="rounded-sm border border-primary/30 bg-primary/5 px-1.5 py-0.5 font-mono text-[10px] text-primary leading-none">Default</span>}
<div className="ml-auto flex items-center gap-1">
{branch.is_head && <span className="font-mono text-[10px] text-muted-foreground">HEAD</span>}
<code className="font-mono text-[10px] text-muted-foreground/60">{branch.oid.slice(0, 7)}</code>
{!isDefault && (
<Button className="size-7 text-muted-foreground hover:text-foreground" onClick={() => { setRenaming(branch.name); setRenameValue(branch.name); }} size="icon" variant="ghost">
<Pencil className="size-3" />
</Button>
)}
{!isDefault && (
<Button className="size-7 text-destructive hover:text-destructive" onClick={() => { if (confirm(`Delete branch "${branch.name}"?`)) onDelete.mutate(branch.name); }} size="icon" variant="ghost">
<Trash2 className="size-3.5" />
</Button>
)}
</div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,341 @@
import { useState, useEffect, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { client, type TreeEntryDto, type TreeKindDto } from "@/client";
import {
Copy,
Check,
GitBranch,
GitCommitHorizontal,
FileText,
Folder,
Package,
ChevronRight,
ChevronDown,
Star,
FolderOpen,
AlertTriangle,
Download,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { workspaceColor, workspaceInitial } from "@/components/shell/shared";
const LANGUAGE_COLORS: Record<string, string> = {
Rust: "#dea584", TypeScript: "#3178c6", JavaScript: "#f1e05a", Python: "#3572A5",
Go: "#00ADD8", Java: "#b07219", "C++": "#f34b7d", C: "#555555", HTML: "#e34c26",
CSS: "#563d7c", Shell: "#89e051", Markdown: "#083fa1", Vue: "#41b883", Ruby: "#701516",
Swift: "#F05138", Kotlin: "#A97BFF", Dart: "#00B4AB", Scala: "#c22d40", R: "#198CE7",
Zig: "#ec915c", Nix: "#7e7eff", CMake: "#DA3434", Dockerfile: "#384d54",
};
function formatTimeAgo(timeSecs: number) {
const now = Math.floor(Date.now() / 1000);
const diff = now - timeSecs;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
return new Date(timeSecs * 1000).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function decodeBase64(base64: string): string {
try { return atob(base64); } catch { return ""; }
}
function formatTimeAgoEntry(ts: string) {
const d = new Date(Number(ts) * 1000);
const diff = Date.now() - d.getTime();
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
if (diff < 2592000000) return `${Math.floor(diff / 86400000)}d`;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function FileIcon({ kind }: { kind: TreeKindDto }) {
if (kind === "tree") return <Folder className="size-4 text-muted-foreground shrink-0" />;
if (kind === "lfs_pointer") return <Package className="size-4 text-muted-foreground shrink-0" />;
return <FileText className="size-4 text-muted-foreground shrink-0" />;
}
function AuthorAvatar({ author }: { author: { name: string } }) {
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(author.name))}>
{workspaceInitial(author.name)}
</span>
);
}
export default function CodePanel({ workspace, repo }: { workspace: string; repo: string }) {
const { data: repoData } = useQuery({
queryKey: ["repo", workspace, repo],
queryFn: async () => { const res = await client.gitGetRepo(workspace, repo); return res.data; },
enabled: Boolean(workspace) && Boolean(repo), retry: false,
});
const defaultBranch = repoData?.default_branch ?? "main";
const [selectedBranch, setSelectedBranch] = useState(defaultBranch);
const [branchOpen, setBranchOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [cloneMethod, setCloneMethod] = useState<"https" | "ssh">("https");
const [treeStack, setTreeStack] = useState<{ oid: string; name: string }[]>([]);
const [viewingFile, setViewingFile] = useState<{ oid: string; name: string; isBinary: boolean } | null>(null);
const [showBlame, setShowBlame] = useState(false);
const [skeletonWidths] = useState(() => Array.from({ length: 12 }, () => 30 + Math.floor(Math.random() * 60)));
const origin = typeof window !== "undefined" ? window.location.origin : "";
const cloneUrl = cloneMethod === "https"
? `${origin}/${workspace}/${repo}.git`
: `git@${origin.replace(/^https?:\/\//, "")}:${workspace}/${repo}.git`;
const handleCopy = async () => {
try { await navigator.clipboard.writeText(cloneUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { /* noop */ }
};
const { data: branchList } = useQuery({
queryKey: ["repo", workspace, repo, "branches"],
queryFn: async () => { const res = await client.gitListBranches(workspace, repo); return res.data.branches; },
enabled: Boolean(workspace) && Boolean(repo), retry: false,
});
const { data: branchInfo } = useQuery({
queryKey: ["repo", workspace, repo, "branch-info", selectedBranch],
queryFn: async () => { const res = await (client as any).gitBranchInfo(workspace, repo, selectedBranch); return res.data.branch; },
enabled: Boolean(workspace) && Boolean(repo), retry: false,
});
const { data: commitInfo } = useQuery({
queryKey: ["repo", workspace, repo, "commit-info", branchInfo?.oid],
queryFn: async () => { const res = await (client as any).gitCommitInfo(workspace, repo, branchInfo!.oid); return res.data.commit; },
enabled: Boolean(branchInfo?.oid), retry: false,
});
const rootTreeOid = commitInfo?.tree_id ?? null;
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (rootTreeOid) { setTreeStack([{ oid: rootTreeOid, name: repo }]); setViewingFile(null); }
else { setTreeStack([]); setViewingFile(null); }
}, [rootTreeOid, repo]);
/* eslint-enable react-hooks/set-state-in-effect */
const currentTreeOid = treeStack.length > 0 ? treeStack[treeStack.length - 1].oid : null;
const currentDirPath = treeStack.slice(1).map((s) => s.name).join("/");
const { data: fastEntries } = useQuery({
queryKey: ["repo", workspace, repo, "tree", currentTreeOid, currentDirPath, "fast"],
queryFn: async () => { const res = await (client as any).gitTreeEntries(workspace, repo, currentTreeOid!, { path: currentDirPath || undefined }); return res.data.entries; },
enabled: Boolean(currentTreeOid), retry: false,
});
const { data: fullEntries } = useQuery({
queryKey: ["repo", workspace, repo, "tree", currentTreeOid, currentDirPath, "full"],
queryFn: async () => { const res = await (client as any).gitTreeEntries(workspace, repo, currentTreeOid!, { path: currentDirPath || undefined, last: true } as any); return res.data.entries; },
enabled: Boolean(currentTreeOid), retry: false,
refetchInterval(query) {
const data = query.state.data;
if (!data || data.length === 0) return false;
const hasCommit = data.some((e: any) => e.last_commit_message);
return hasCommit ? false : 2000;
},
});
const displayEntries = (fullEntries ?? fastEntries) ?? [];
const { data: latestCommit } = useQuery({
queryKey: ["repo", workspace, repo, "latest-commit", selectedBranch],
queryFn: async () => { const res = await client.gitCommitHistory(workspace, repo, { branch: selectedBranch, limit: 1 }); return res.data.commits[0] ?? null; },
enabled: Boolean(workspace) && Boolean(repo), retry: false,
});
const { data: blobData, isLoading: blobLoading } = useQuery({
queryKey: ["repo", workspace, repo, "blob", viewingFile?.oid],
queryFn: async () => { const res = await (client as any).gitBlobInfo(workspace, repo, viewingFile!.oid, { path: viewingFile!.name } as any); return res.data; },
enabled: Boolean(viewingFile) && !viewingFile?.isBinary, retry: false,
});
const { data: blameLines } = useQuery({
queryKey: ["repo", workspace, repo, "blame", viewingFile?.name],
queryFn: async () => { const res: any = await client.gitBlameFile(workspace, repo, { path: viewingFile!.name, start_line: 1, end_line: 999999 }); return res.data?.lines as { commit_oid?: string | null; content: string; line_no: number }[] | undefined; },
enabled: Boolean(showBlame && viewingFile && !viewingFile?.isBinary), retry: false,
});
const blameOids = blameLines ? [...new Set(blameLines.map((l) => l.commit_oid).filter(Boolean))] : [];
const { data: blameCommits } = useQuery({
queryKey: ["repo", workspace, repo, "blame-commits", ...blameOids.slice(0, 10)],
queryFn: async () => {
const results: Record<string, string> = {};
for (const oid of blameOids.slice(0, 10)) {
if (oid) { try { const res = await (client as any).gitCommitInfo(workspace, repo, oid); results[oid] = res.data.commit?.author?.name ?? "?"; } catch { results[oid] = "?"; } }
}
return results;
},
enabled: blameOids.length > 0, retry: false,
});
const { data: languages = [] } = useQuery({
queryKey: ["repo", workspace, repo, "languages"],
queryFn: async () => { const res = await client.gitGetLanguages(workspace, repo); return res.data; },
enabled: Boolean(workspace) && Boolean(repo), retry: false,
});
const handleBranchSelect = useCallback((name: string) => { setSelectedBranch(name); setBranchOpen(false); }, []);
const handleTreeNavigate = useCallback((entry: TreeEntryDto) => { setViewingFile(null); setTreeStack((prev) => [...prev, { oid: entry.oid, name: entry.name }]); }, []);
const handleFileView = useCallback((entry: TreeEntryDto) => { setViewingFile({ oid: entry.oid, name: entry.name, isBinary: entry.is_binary }); }, []);
const handleBreadcrumbClick = useCallback((index: number) => { setViewingFile(null); setTreeStack((prev) => prev.slice(0, index + 1)); }, []);
const entries = displayEntries;
return (
<div className="space-y-4">
{/* Top action bar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative">
<button className="flex items-center gap-1.5 rounded-sm border border-border px-3 py-1.5 text-[13px] font-medium text-foreground bg-card transition-colors hover:bg-muted/40" onClick={() => setBranchOpen(!branchOpen)}>
<GitBranch className="size-3.5 text-muted-foreground" />
<span className="font-heading">{selectedBranch}</span>
<ChevronDown className={cn("size-3 text-muted-foreground transition-transform", branchOpen && "rotate-180")} />
</button>
{branchOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setBranchOpen(false)} />
<div className="absolute top-full left-0 mt-1 z-50 w-56 rounded-md border border-border bg-card shadow-lg overflow-hidden">
<div className="px-3 py-2 border-b border-border bg-muted/50"><span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">Switch branch</span></div>
<div className="max-h-56 overflow-y-auto">
{branchList && branchList.length > 0 ? branchList.map((b: any) => (
<button className={cn("flex items-center gap-2 px-3 py-2 text-[13px] w-full transition-colors", b.name === selectedBranch ? "bg-primary/5 text-foreground" : "text-foreground hover:bg-muted/40")} key={b.name} onClick={() => handleBranchSelect(b.name)}>
<GitBranch className={cn("size-3", b.name === defaultBranch ? "text-primary" : "text-muted-foreground")} />
<span className="font-heading font-medium">{b.name}</span>
{b.name === defaultBranch && <Star className="size-2.5 text-primary ml-auto" />}
</button>
)) : (
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground">No branches found</div>
)}
</div>
</div>
</>
)}
</div>
<div className="ml-auto flex items-center gap-1 rounded-sm border border-border overflow-hidden">
<div className="flex items-center">
<button className={cn("px-2 py-1 text-[11px] font-mono transition-colors", cloneMethod === "https" ? "text-foreground bg-muted/50" : "text-muted-foreground hover:text-foreground")} onClick={() => setCloneMethod("https")}>HTTPS</button>
<button className={cn("px-2 py-1 text-[11px] font-mono transition-colors", cloneMethod === "ssh" ? "text-foreground bg-muted/50" : "text-muted-foreground hover:text-foreground")} onClick={() => setCloneMethod("ssh")}>SSH</button>
</div>
<code className="flex-1 px-2 py-1 font-mono text-[12px] text-foreground truncate select-all border-l border-border">{cloneUrl}</code>
<Button className="size-6 text-muted-foreground" onClick={handleCopy} size="icon" variant="ghost">{copied ? <Check className="size-3" /> : <Copy className="size-3" />}</Button>
</div>
<span className="h-5 w-px bg-border" />
<div className="flex items-center border border-border rounded-sm overflow-hidden">
<a className="flex items-center gap-1 px-2 py-1 text-[11px] font-mono text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors" href={`/api/v1/workspace/${workspace}/repos/${repo}/git/archive/tar`} download><Download className="size-3" /> TAR</a>
<span className="w-px h-4 bg-border" />
<a className="flex items-center gap-1 px-2 py-1 text-[11px] font-mono text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors" href={`/api/v1/workspace/${workspace}/repos/${repo}/git/archive/zip`} download><Download className="size-3" /> ZIP</a>
</div>
</div>
{languages.length > 0 && (
<div className="flex items-center gap-0.5 h-1.5 rounded-full overflow-hidden bg-muted/30">
{languages.map((lang: any) => (
<div className="h-full transition-all" key={lang.language} style={{ width: `${lang.percentage}%`, backgroundColor: LANGUAGE_COLORS[lang.language] ?? "#888" }} title={`${lang.language}: ${lang.percentage.toFixed(1)}%`} />
))}
</div>
)}
{latestCommit && (
<div className="flex items-center gap-2 rounded-sm border border-border bg-muted/30 px-3 py-2">
<GitCommitHorizontal className="size-4 text-muted-foreground shrink-0" />
<div className="flex items-center gap-1.5 min-w-0">
<AuthorAvatar author={{ name: latestCommit.author?.name ?? "unknown" }} />
<span className="font-heading text-[13px] font-semibold text-foreground truncate">{latestCommit.summary}</span>
</div>
<span className="font-mono text-[11px] text-muted-foreground shrink-0 ml-auto">{latestCommit.author?.time_secs ? formatTimeAgo(latestCommit.author.time_secs) : ""}</span>
<code className="rounded bg-muted/60 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground shrink-0">{latestCommit.oid.slice(0, 7)}</code>
</div>
)}
<div className="rounded-md border border-border overflow-hidden">
<div className="flex items-center gap-0.5 bg-muted/50 px-3 py-2 border-b border-border overflow-x-auto">
{treeStack.map((item, i) => (
<button className={cn("flex items-center gap-0.5 text-[12px] shrink-0 transition-colors hover:text-foreground", i === treeStack.length - 1 ? "text-foreground font-medium" : "text-muted-foreground")} key={`${item.oid}-${i}`} onClick={() => handleBreadcrumbClick(i)}>
{i === 0 ? <FolderOpen className="size-3.5" /> : <span className="text-muted-foreground/40 mx-0.5">/</span>}
<span className="font-mono truncate max-w-[120px]">{item.name}</span>
</button>
))}
{viewingFile && (
<>
<ChevronRight className="size-3 text-muted-foreground/40 mx-0.5" />
<span className="text-[12px] text-foreground font-medium shrink-0">{viewingFile.name}</span>
<button className="ml-3 text-[11px] text-muted-foreground hover:text-foreground shrink-0" onClick={() => setShowBlame(!showBlame)}>{showBlame ? "code" : "blame"}</button>
<button className="ml-2 text-[11px] text-muted-foreground hover:text-foreground shrink-0" onClick={() => setViewingFile(null)}>&times; close</button>
</>
)}
</div>
{viewingFile ? (
viewingFile.isBinary ? (
<div className="py-12 text-center"><AlertTriangle className="mx-auto size-5 text-muted-foreground/20" /><p className="mt-2 text-[13px] text-muted-foreground">Binary file</p></div>
) : blobLoading ? (
<div className="px-4 py-8 space-y-2">{skeletonWidths.map((w, i) => <div className="h-4 animate-pulse rounded bg-muted/50" key={i} style={{ width: `${w}%` }} />)}</div>
) : blobData ? (
<div className="overflow-auto max-h-[600px]">
{showBlame && blameLines ? (
<table className="w-full font-mono text-[12px] leading-relaxed">
<tbody>
{blameLines.map((line, i) => (
<tr className="hover:bg-muted/20" key={i}>
<td className="w-16 min-w-[64px] text-right px-1 text-muted-foreground/40 select-none border-r border-border">{line.line_no}</td>
{line.commit_oid && <td className="w-14 min-w-[56px] text-right px-1 text-muted-foreground/50 select-all truncate" title={blameCommits?.[line.commit_oid] ?? ""}><span className="text-[10px]">{blameCommits?.[line.commit_oid] ?? line.commit_oid.slice(0, 6)}</span></td>}
<td className="px-2 whitespace-pre text-foreground">{line.content}</td>
</tr>
))}
</tbody>
</table>
) : (
<pre className="px-4 py-3 font-mono text-[12px] text-foreground leading-relaxed whitespace-pre">{decodeBase64((blobData as any).blob)}</pre>
)}
</div>
) : (
<div className="py-12 text-center"><p className="text-[13px] text-muted-foreground">Could not load file content</p></div>
)
) : entries.length > 0 ? (
<div>
{entries.slice().sort((a: any, b: any) => { if (a.kind !== b.kind) return a.kind === "tree" ? -1 : 1; return a.name.localeCompare(b.name); }).map((entry: any) => (
<div className="flex items-center gap-3 border-b border-border px-3 py-2 last:border-b-0 transition-colors hover:bg-muted/40 cursor-pointer" key={entry.name} onClick={() => { if (entry.kind === "tree") handleTreeNavigate(entry); else handleFileView(entry); }}>
<FileIcon kind={entry.kind} />
<span className={cn("text-[13px] truncate", entry.kind === "tree" ? "font-heading font-semibold text-foreground" : "text-foreground")}>{entry.name}</span>
{entry.is_lfs && <span className="rounded-sm border border-border px-1 py-0.5 font-mono text-[9px] text-muted-foreground leading-none shrink-0">LFS</span>}
<div className="ml-auto flex items-center gap-3 min-w-0">
{entry.last_commit_message && <span className="font-mono text-[11px] text-muted-foreground/60 truncate max-w-[280px] hidden sm:block">{entry.last_commit_message}</span>}
{entry.last_commit_author_name && <span className="font-mono text-[11px] text-muted-foreground shrink-0">{entry.last_commit_author_name}</span>}
{entry.last_commit_time && <span className="font-mono text-[10px] text-muted-foreground/40 shrink-0">{formatTimeAgoEntry(entry.last_commit_time)}</span>}
</div>
</div>
))}
</div>
) : currentTreeOid ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="grid size-12 place-items-center rounded-xl bg-muted/40"><Folder className="size-5 text-muted-foreground/40" /></div>
<p className="mt-3 text-[13px] font-medium text-muted-foreground">Empty directory</p>
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="grid size-14 place-items-center rounded-2xl bg-muted/40"><GitCommitHorizontal className="size-6 text-muted-foreground/40" /></div>
<h3 className="mt-4 font-heading text-sm font-semibold text-foreground">No content yet</h3>
<p className="mt-1.5 max-w-[280px] text-[12px] leading-relaxed text-muted-foreground">This repository is empty. Push your first commit to get started.</p>
</div>
)}
</div>
{languages.length > 1 && (
<div className="flex items-center gap-3 flex-wrap text-[11px] text-muted-foreground">
{languages.map((lang: any) => (
<span className="flex items-center gap-1" key={lang.language}>
<span className="inline-block size-2 rounded-sm" style={{ backgroundColor: LANGUAGE_COLORS[lang.language] ?? "#888" }} />
{lang.language} <span className="font-mono text-[10px]">{lang.percentage.toFixed(1)}%</span>
</span>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,141 @@
import { useState } from "react";
import { Link } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { client } from "@/client";
import { GitCommitHorizontal, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { workspaceColor, workspaceInitial } from "@/components/shell/shared";
import { useOffsetState } from "./use-offset";
import PaginationBar from "./pagination";
function formatTimeAgo(timeSecs: number) {
const now = Math.floor(Date.now() / 1000);
const diff = now - timeSecs;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
return new Date(timeSecs * 1000).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function AuthorAvatar({ author }: { author: { name: string } }) {
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(author.name),
)}
>
{workspaceInitial(author.name)}
</span>
);
}
const LIMIT = 30;
export default function CommitsPanel({ workspace, repo }: { workspace: string; repo: string }) {
const [offset, setOffset] = useOffsetState();
const { data: repoData } = useQuery({
queryKey: ["repo", workspace, repo],
queryFn: async () => {
const res = await client.gitGetRepo(workspace, repo);
return res.data;
},
enabled: Boolean(workspace) && Boolean(repo),
retry: false,
});
const defaultBranch = repoData?.default_branch ?? "main";
const [search, setSearch] = useState("");
const [selectedBranch, setSelectedBranch] = useState(defaultBranch);
const { data: commits = [], isLoading } = useQuery({
queryKey: ["repo", workspace, repo, "commits", selectedBranch, offset],
queryFn: async () => {
const res = await client.gitCommitHistory(workspace, repo, { branch: selectedBranch, skip: offset, limit: LIMIT });
return res.data.commits;
},
enabled: Boolean(workspace) && Boolean(repo),
retry: false,
});
const { data: branches = [] } = useQuery({
queryKey: ["repo", workspace, repo, "branches"],
queryFn: async () => {
const res = await client.gitListBranches(workspace, repo);
return res.data.branches;
},
enabled: Boolean(workspace) && Boolean(repo),
retry: false,
});
const filtered = search
? commits.filter((c: any) => c.summary.toLowerCase().includes(search.toLowerCase()) || c.oid.startsWith(search))
: commits;
return (
<div>
<div className="flex items-center gap-3 mb-4">
<select
className="h-8 rounded-sm border border-border bg-card px-2 text-[12px] font-mono text-foreground"
value={selectedBranch}
onChange={(e) => { setSelectedBranch(e.target.value); setOffset(0); }}
>
{branches.map((b: any) => (
<option key={b.name} value={b.name}>{b.name}</option>
))}
</select>
<div className="ml-auto relative">
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input className="h-7 w-52 rounded-sm border-border bg-card pl-7 text-[12px]" placeholder="Search commits..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
</div>
<div className="rounded-md border border-border overflow-hidden">
{isLoading ? (
<div className="px-4 py-2">
{Array.from({ length: 8 }).map((_, i) => <div className="h-6 animate-pulse rounded bg-muted/50 my-1.5" key={i} />)}
</div>
) : filtered.length > 0 ? (
<>
<div>
{filtered.map((commit: any) => (
<Link
className="flex items-center gap-3 border-b border-border px-4 py-2.5 last:border-b-0 transition-colors hover:bg-muted/40"
key={commit.oid}
to={`/${workspace}/repo/${repo}/commits/${commit.oid}`}
>
<GitCommitHorizontal className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<span className="font-heading text-[13px] font-semibold text-foreground truncate block leading-tight">{commit.summary}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<AuthorAvatar author={{ name: commit.author?.name ?? "unknown" }} />
<span className="font-mono text-[11px] text-muted-foreground">{commit.author?.name}</span>
<span className="font-mono text-[11px] text-muted-foreground">{commit.author?.time_secs ? formatTimeAgo(commit.author.time_secs) : ""}</span>
<code className="rounded bg-muted/60 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">{commit.oid.slice(0, 7)}</code>
</div>
</Link>
))}
</div>
<PaginationBar offset={offset} limit={LIMIT} hasMore={commits.length >= LIMIT} onPrev={() => setOffset(Math.max(0, offset - LIMIT))} onNext={() => setOffset(offset + LIMIT)} />
</>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="grid size-14 place-items-center rounded-2xl bg-muted/40">
<GitCommitHorizontal className="size-6 text-muted-foreground/40" />
</div>
<h3 className="mt-4 font-heading text-sm font-semibold text-foreground">
{search ? "No matching commits" : "No commits yet"}
</h3>
<p className="mt-1.5 max-w-[280px] text-[12px] leading-relaxed text-muted-foreground">
{search ? `No commits match "${search}"` : "Push your first commit to this branch to get started."}
</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
import { useQuery } from "@tanstack/react-query";
import { client } from "@/client";
import { Users, GitCommitHorizontal } from "lucide-react";
import { useOffsetState } from "./use-offset";
import PaginationBar from "./pagination";
const LIMIT = 20;
export default function ContributorsPanel({ workspace, repo }: { workspace: string; repo: string }) {
const [offset, setOffset] = useOffsetState();
const { data: contributors = [], isLoading } = useQuery({
queryKey: ["repo", workspace, repo, "contributors", offset],
queryFn: async () => {
const res = await client.gitListContributors(workspace, repo, { params: { offset, limit: LIMIT } } as any);
return res.data;
},
enabled: Boolean(workspace) && Boolean(repo),
retry: false,
});
const sorted = [...contributors].sort((a: any, b: any) => b.commit_count - a.commit_count);
return (
<div>
<div className="rounded-md border border-border overflow-hidden">
<div className="flex items-center gap-2 bg-muted/50 px-3 py-2 border-b border-border">
<span className="font-mono text-[11px] text-muted-foreground">Contributors</span>
</div>
{isLoading ? (
<div className="px-4 py-2">{Array.from({ length: 5 }).map((_, i) => <div className="h-6 animate-pulse rounded bg-muted/50 my-1.5" key={i} />)}</div>
) : sorted.length > 0 ? (
<>
<div>
{sorted.map((c: any) => (
<div className="flex items-center gap-3 border-b border-border px-4 py-2.5 last:border-b-0" key={c.email}>
<Users className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<span className="font-heading text-[13px] font-semibold text-foreground">{c.name}</span>
<span className="font-mono text-[11px] text-muted-foreground/60 ml-1">{c.email}</span>
</div>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
<GitCommitHorizontal className="size-3" />
<span>{c.commit_count} commit{c.commit_count !== 1 ? "s" : ""}</span>
</div>
</div>
))}
</div>
<PaginationBar offset={offset} limit={LIMIT} hasMore={contributors.length >= LIMIT} onPrev={() => setOffset(Math.max(0, offset - LIMIT))} onNext={() => setOffset(offset + LIMIT)} />
</>
) : (
<div className="py-12 text-center">
<Users className="mx-auto size-5 text-muted-foreground/20" />
<p className="mt-3 text-[13px] text-muted-foreground">No contributors found</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { Button } from "@/components/ui/button";
type Props = {
offset: number;
limit: number;
hasMore: boolean;
onPrev: () => void;
onNext: () => void;
};
export default function PaginationBar({ offset, limit, hasMore, onPrev, onNext }: Props) {
const page = Math.floor(offset / limit) + 1;
return (
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-4 py-2">
<span className="font-mono text-[11px] text-muted-foreground">Page {page}</span>
<div className="flex items-center gap-2">
<Button
className="h-7 px-3 text-[11px]"
disabled={offset === 0}
onClick={onPrev}
variant="outline"
>
Previous
</Button>
<Button
className="h-7 px-3 text-[11px]"
disabled={!hasMore}
onClick={onNext}
variant="outline"
>
Next
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,174 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/client";
import {
Lock, Globe, Archive, Star, Eye, EyeOff,
GitBranch, FileText, GitCommitHorizontal, Tag, Users,
} from "lucide-react";
import { cn } from "@/lib/utils";
import CodePanel from "./code-panel";
import CommitsPanel from "./commits-panel";
import BranchesPanel from "./branches-panel";
import TagsPanel from "./tags-panel";
import ContributorsPanel from "./contributors-panel";
function formatSize(bytes: number) {
if (bytes === 0) return "Empty";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(date: string) {
const diff = Date.now() - new Date(date).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`;
}
type Props = {
workspace: string;
repo: string;
};
export default function RepoView({ workspace, repo }: Props) {
const qc = useQueryClient();
const { data: repoData, isLoading } = useQuery({
queryKey: ["repo", workspace, repo],
queryFn: async () => { const res = await client.gitGetRepo(workspace, repo); return res.data; },
enabled: Boolean(workspace) && Boolean(repo),
retry: false,
});
const { data: starData } = useQuery({
queryKey: ["repo", workspace, repo, "star"],
queryFn: async () => { const res: any = await client.gitStarStatus(workspace, repo); return res.data as { starred: boolean; count: number }; },
enabled: Boolean(workspace) && Boolean(repo),
retry: false,
});
const starRepo = useMutation({
mutationFn: async () => { if (starData?.starred) await client.gitUnstarRepo(workspace, repo); else await client.gitStarRepo(workspace, repo); },
onSuccess: () => qc.invalidateQueries({ queryKey: ["repo", workspace, repo, "star"] }),
});
const { data: watchData } = useQuery({
queryKey: ["repo", workspace, repo, "watch"],
queryFn: async () => { const res: any = await client.gitWatchStatus(workspace, repo); return res.data as { watching: boolean; count: number }; },
enabled: Boolean(workspace) && Boolean(repo),
retry: false,
});
const watchRepo = useMutation({
mutationFn: async () => { if (watchData?.watching) await client.gitUnwatchRepo(workspace, repo); else await client.gitWatchRepo(workspace, repo, {} as any); },
onSuccess: () => qc.invalidateQueries({ queryKey: ["repo", workspace, repo, "watch"] }),
});
const { data: readme } = useQuery({
queryKey: ["repo", workspace, repo, "readme"],
queryFn: async () => { const res = await client.gitGetReadme(workspace, repo); return res.data; },
enabled: Boolean(workspace) && Boolean(repo),
retry: false,
});
const [activeTab, setActiveTab] = useState(readme?.html ? "readme" : "code");
if (isLoading) {
return (
<div className="px-8 py-10 space-y-3">
<div className="h-7 animate-pulse rounded bg-muted/50 w-64" />
<div className="h-5 animate-pulse rounded bg-muted/50 w-80" />
<div className="h-4 animate-pulse rounded bg-muted/50 w-48" />
</div>
);
}
if (!repoData) {
return (
<div className="flex flex-col items-center justify-center px-8 py-20 text-center">
<div className="grid size-16 place-items-center rounded-2xl bg-muted/40"><GitBranch className="size-7 text-muted-foreground/40" /></div>
<h2 className="mt-5 font-heading text-lg font-semibold text-foreground">Repository not found</h2>
<p className="mt-2 max-w-sm text-[13px] text-muted-foreground">This repository doesn't exist or you don't have permission to view it.</p>
</div>
);
}
const tabs = [
...(readme?.html ? [{ id: "readme", label: "README", icon: <FileText className="size-3.5" /> }] : []),
{ id: "code", label: "Code", icon: <FileText className="size-3.5" /> },
{ id: "commits", label: "Commits", icon: <GitCommitHorizontal className="size-3.5" /> },
{ id: "branches", label: "Branches", icon: <GitBranch className="size-3.5" /> },
{ id: "tags", label: "Tags", icon: <Tag className="size-3.5" /> },
{ id: "contributors", label: "Contributors", icon: <Users className="size-3.5" /> },
];
return (
<div className="w-full px-6 py-8 lg:px-8 lg:py-10">
{/* Header */}
<div className="flex items-center gap-2 flex-wrap">
<h1 className="font-heading text-lg font-bold text-foreground">{repoData.name}</h1>
{repoData.visibility === "private" ? (
<span className="inline-flex items-center gap-1 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-muted-foreground"><Lock className="size-2.5" />Private</span>
) : (
<span className="inline-flex items-center gap-1 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-muted-foreground"><Globe className="size-2.5" />Public</span>
)}
{repoData.is_archived && <span className="inline-flex items-center gap-1 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-muted-foreground"><Archive className="size-2.5" />Archived</span>}
{repoData.is_mirror && <span className="inline-flex items-center gap-1 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">Mirror</span>}
{repoData.is_template && <span className="inline-flex items-center gap-1 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">Template</span>}
</div>
{repoData.description && <p className="mt-1 text-[13px] text-muted-foreground max-w-2xl">{repoData.description}</p>}
<div className="mt-2 flex items-center gap-3 font-mono text-[11px] text-muted-foreground flex-wrap">
<span><span className="text-foreground/60">default branch:</span> {repoData.default_branch}</span>
<span className="text-muted-foreground/30">|</span>
<span>{formatSize(repoData.size_bytes)}</span>
<span className="text-muted-foreground/30">|</span>
<span>created {formatDate(repoData.created_at)}</span>
<div className="flex items-center gap-1.5 ml-2">
<button className={cn("inline-flex items-center gap-1 rounded-sm border px-2 py-1 text-[11px] transition-colors", starData?.starred ? "border-primary/40 bg-primary/5 text-primary" : "border-border text-muted-foreground hover:text-foreground")} disabled={starRepo.isPending} onClick={() => starRepo.mutate()}>
<Star className={cn("size-3", starData?.starred && "fill-primary")} />{starData?.starred ? "Starred" : "Star"}
{typeof starData?.count === "number" && <span className="font-mono text-[10px]">{starData.count}</span>}
</button>
<button className={cn("inline-flex items-center gap-1 rounded-sm border px-2 py-1 text-[11px] transition-colors", watchData?.watching ? "border-primary/40 bg-primary/5 text-primary" : "border-border text-muted-foreground hover:text-foreground")} disabled={watchRepo.isPending} onClick={() => watchRepo.mutate()}>
{watchData?.watching ? <Eye className="size-3" /> : <EyeOff className="size-3" />}{watchData?.watching ? "Watching" : "Watch"}
{typeof watchData?.count === "number" && <span className="font-mono text-[10px]">{watchData.count}</span>}
</button>
</div>
</div>
{/* Tab nav */}
<nav className="mt-6 flex items-center gap-0 overflow-x-auto border-b border-border">
{tabs.map((tab) => (
<button
className={cn(
"inline-flex shrink-0 items-center gap-1.5 px-3 h-8 text-[13px] font-medium border-b-2 border-transparent transition-colors -mb-px",
activeTab === tab.id ? "text-foreground border-primary" : "text-muted-foreground hover:text-foreground hover:border-muted-foreground/30",
)}
key={tab.id}
onClick={() => setActiveTab(tab.id)}
>
{tab.icon}
{tab.label}
</button>
))}
</nav>
{/* Tab content */}
<div className="mt-6">
{activeTab === "code" && <CodePanel workspace={workspace} repo={repo} />}
{activeTab === "readme" && readme?.html && (
<div className="prose prose-sm dark:prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: readme.html }} />
)}
{activeTab === "commits" && <CommitsPanel workspace={workspace} repo={repo} />}
{activeTab === "branches" && <BranchesPanel workspace={workspace} repo={repo} />}
{activeTab === "tags" && <TagsPanel workspace={workspace} repo={repo} />}
{activeTab === "contributors" && <ContributorsPanel workspace={workspace} repo={repo} />}
</div>
</div>
);
}

View File

@ -0,0 +1,107 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tag, Plus, Trash2 } from "lucide-react";
import { useOffsetState } from "./use-offset";
import PaginationBar from "./pagination";
const LIMIT = 20;
export default function TagsPanel({ workspace, repo }: { workspace: string; repo: string }) {
const qc = useQueryClient();
const [offset, setOffset] = useOffsetState();
const [newName, setNewName] = useState("");
const [newOid, setNewOid] = useState("");
const [newMessage, setNewMessage] = useState("");
const [showCreate, setShowCreate] = useState(false);
const { data: tags = [], isLoading } = useQuery({
queryKey: ["repo", workspace, repo, "tags", offset],
queryFn: async () => {
const res = await client.gitListTags(workspace, repo, { params: { offset, limit: LIMIT } } as any);
return (res.data as any).tags ?? [];
},
enabled: Boolean(workspace) && Boolean(repo),
retry: false,
});
const createTag = useMutation({
mutationFn: async () => {
await client.gitInitTag(workspace, repo, { name: newName, oid: newOid || undefined, message: newMessage || undefined } as any);
},
onSuccess: () => { setShowCreate(false); setNewName(""); setNewOid(""); setNewMessage(""); qc.invalidateQueries({ queryKey: ["repo", workspace, repo, "tags"] }); },
});
const deleteTag = useMutation({
mutationFn: async (name: string) => { await client.gitDeleteTag(workspace, repo, { name } as any); },
onSuccess: () => qc.invalidateQueries({ queryKey: ["repo", workspace, repo, "tags"] }),
});
const sorted = [...tags].sort((a: any, b: any) => a.name.localeCompare(b.name));
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<p className="font-mono text-[11px] text-muted-foreground">{tags.length} tag{tags.length !== 1 ? "s" : ""}</p>
<Button className="h-8 px-3 text-[12px]" onClick={() => setShowCreate(!showCreate)}>
<Plus className="size-3.5 mr-1" /> New tag
</Button>
</div>
{showCreate && (
<div className="rounded-md border border-border p-4 space-y-3">
<Input className="h-9 text-[13px]" placeholder="Tag name (e.g. v1.0.0)" value={newName} onChange={(e) => setNewName(e.target.value)} />
<Input className="h-9 text-[13px]" placeholder="Target commit OID (optional, defaults to HEAD)" value={newOid} onChange={(e) => setNewOid(e.target.value)} />
<Input className="h-9 text-[13px]" placeholder="Tag message (optional)" value={newMessage} onChange={(e) => setNewMessage(e.target.value)} />
<div className="flex gap-2">
<Button className="h-8 px-3 text-[12px]" disabled={!newName.trim() || createTag.isPending} onClick={() => createTag.mutate()}>
{createTag.isPending ? "Creating..." : "Create tag"}
</Button>
<Button className="h-8 px-3 text-[12px]" onClick={() => setShowCreate(false)} variant="outline">Cancel</Button>
</div>
</div>
)}
<div className="rounded-md border border-border overflow-hidden">
{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>
) : sorted.length > 0 ? (
<>
<div>
{sorted.map((tag: any) => (
<div className="flex items-center gap-3 border-b border-border px-4 py-2.5 last:border-b-0 transition-colors hover:bg-muted/40" key={tag.name}>
<Tag className="size-4 text-muted-foreground shrink-0" />
<span className="font-heading text-[13px] font-semibold text-foreground">{tag.name}</span>
{tag.is_annotated && <span className="rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">annotated</span>}
<div className="ml-auto flex items-center gap-2">
<code className="font-mono text-[10px] text-muted-foreground/60">{tag.oid.slice(0, 7)}</code>
{tag.tagger && <span className="font-mono text-[11px] text-muted-foreground">{tag.tagger}</span>}
<Button className="size-7 text-destructive hover:text-destructive" onClick={() => { if (confirm(`Delete tag "${tag.name}"?`)) deleteTag.mutate(tag.name); }} size="icon" variant="ghost">
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
))}
</div>
<PaginationBar offset={offset} limit={LIMIT} hasMore={tags.length >= LIMIT} onPrev={() => setOffset(Math.max(0, offset - LIMIT))} onNext={() => setOffset(offset + LIMIT)} />
</>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="grid size-14 place-items-center rounded-2xl bg-muted/40">
<Tag className="size-6 text-muted-foreground/40" />
</div>
<h3 className="mt-4 font-heading text-sm font-semibold text-foreground">No tags yet</h3>
<p className="mt-1.5 max-w-[280px] text-[12px] leading-relaxed text-muted-foreground">
Tags mark specific points in repository history, typically used for releases.
</p>
<Button className="mt-4 h-8 px-3 text-[12px]" onClick={() => setShowCreate(true)}>
<Plus className="size-3.5 mr-1" /> Create first tag
</Button>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
import { useState } from "react";
import { useSearchParams } from "react-router";
/**
* Pagination offset state.
* Uses URL search params when available (route context),
* falls back to local state when URL params are not present (drawer context).
*/
export function useOffsetState(initial = 0): [number, (n: number) => void] {
const [searchParams, setSearchParams] = useSearchParams();
const [localOffset, setLocalOffset] = useState(initial);
const urlOffset = searchParams.get("offset");
const isUrlDriven = urlOffset !== null;
const offset = isUrlDriven ? Number(urlOffset) : localOffset;
const setOffset = (n: number) => {
if (isUrlDriven) {
const next = new URLSearchParams(searchParams);
if (n <= 0) next.delete("offset");
else next.set("offset", String(n));
setSearchParams(next, { replace: true });
} else {
setLocalOffset(Math.max(0, n));
}
};
return [offset, setOffset];
}