fix(repo): update repository page components
This commit is contained in:
parent
71d2e059a6
commit
7a1b03060e
@ -1,205 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
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";
|
||||
import BranchesPanel from "@/components/repo/branches-panel";
|
||||
|
||||
export default function BranchesTab() {
|
||||
const { projectName = "", repoName = "" } = useParams();
|
||||
|
||||
const { data: repoData } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitGetRepo(projectName, repoName);
|
||||
return res.data;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
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", projectName, repoName, "branches"],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitListBranches(projectName, repoName);
|
||||
return res.data.branches;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const createBranch = useMutation({
|
||||
mutationFn: async () => { await client.gitForkBranch(projectName, repoName, { name: newBranchName, oid: newBranchSource } as any); },
|
||||
onSuccess: () => { setShowCreate(false); setNewBranchName(""); qc.invalidateQueries({ queryKey: ["repo", projectName, repoName, "branches"] }); },
|
||||
});
|
||||
|
||||
const deleteBranch = useMutation({
|
||||
mutationFn: async (name: string) => { await client.gitDeleteBranch(projectName, repoName, name); },
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["repo", projectName, repoName, "branches"] }),
|
||||
});
|
||||
|
||||
const renameBranch = useMutation({
|
||||
mutationFn: async ({ oldName, newName }: { oldName: string; newName: string }) => { await (client as any).gitRenameBranch(projectName, repoName, oldName, { new_branch: newName }); },
|
||||
onSuccess: () => { setRenaming(null); setRenameValue(""); qc.invalidateQueries({ queryKey: ["repo", projectName, repoName, "branches"] }); },
|
||||
});
|
||||
|
||||
const filtered = search ? branches.filter((b) => b.name.toLowerCase().includes(search.toLowerCase())) : branches;
|
||||
const defaultBranches = filtered.filter((b) => b.name === defaultBranch);
|
||||
const otherBranches = filtered.filter((b) => 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) => <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) => <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 projectName={projectName} repoName={repoName} branches={branches} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchComparison({ projectName, repoName, branches }: { projectName: string; repoName: string; branches: { name: string }[] }) {
|
||||
const [oldBranch, setOldBranch] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
|
||||
const { data: diffResult } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "branch-diff", oldBranch, newBranch],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitDiffBranches(projectName, repoName, { 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) => <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) => <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-green-600">+{diffResult.stats.insertions}</span>
|
||||
<span className="text-red-600">-{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>
|
||||
);
|
||||
return <BranchesPanel workspace={projectName} repo={repoName} />;
|
||||
}
|
||||
@ -1,631 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
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 now = Date.now();
|
||||
const diff = 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>
|
||||
);
|
||||
}
|
||||
import { useParams } from "react-router";
|
||||
import CodePanel from "@/components/repo/code-panel";
|
||||
|
||||
export default function CodeTab() {
|
||||
const { projectName = "", repoName = "" } = useParams();
|
||||
|
||||
// Get default branch
|
||||
const { data: repoData } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitGetRepo(projectName, repoName);
|
||||
return res.data;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
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 [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const cloneUrl = cloneMethod === "https"
|
||||
? `${origin}/${projectName}/${repoName}.git`
|
||||
: `git@${origin.replace(/^https?:\/\//, "")}:${projectName}/${repoName}.git`;
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cloneUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch { /* noop */ }
|
||||
};
|
||||
|
||||
// Branch list
|
||||
const { data: branchList } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "branches"],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitListBranches(projectName, repoName);
|
||||
return res.data.branches;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Branch info → commit OID
|
||||
const { data: branchInfo } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "branch-info", selectedBranch],
|
||||
queryFn: async () => {
|
||||
const res = await (client as any).gitBranchInfo(projectName, repoName, selectedBranch);
|
||||
return res.data.branch;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Commit info → tree OID
|
||||
const { data: commitInfo } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "commit-info", branchInfo?.oid],
|
||||
queryFn: async () => {
|
||||
const res = await (client as any).gitCommitInfo(projectName, repoName, branchInfo!.oid);
|
||||
return res.data.commit;
|
||||
},
|
||||
enabled: Boolean(branchInfo?.oid),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rootTreeOid = commitInfo?.tree_id ?? null;
|
||||
|
||||
// Reset navigation when branch changes
|
||||
|
||||
useEffect(() => {
|
||||
if (rootTreeOid) {
|
||||
setTreeStack([{ oid: rootTreeOid, name: repoName }]);
|
||||
setViewingFile(null);
|
||||
setSearchParams({}, { replace: true });
|
||||
} else {
|
||||
setTreeStack([]);
|
||||
setViewingFile(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rootTreeOid, repoName]);
|
||||
|
||||
// URL → tree stack sync for browser back/forward
|
||||
const urlDirPath = searchParams.get("path") ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
const curPath = treeStack.slice(1).map((s) => s.name).join("/");
|
||||
if (!rootTreeOid || urlDirPath === curPath) return;
|
||||
setViewingFile(null);
|
||||
if (!urlDirPath) {
|
||||
setTreeStack([{ oid: rootTreeOid, name: repoName }]);
|
||||
} else {
|
||||
(async () => {
|
||||
const segments = urlDirPath.split("/");
|
||||
const stack = [{ oid: rootTreeOid, name: repoName }];
|
||||
let curTree = rootTreeOid;
|
||||
for (const seg of segments) {
|
||||
try {
|
||||
const sub = await (client as any).gitTreeEntryByPath(projectName, repoName, curTree, { path: seg });
|
||||
if (sub.data.entry && sub.data.entry.kind === "tree") {
|
||||
stack.push({ oid: sub.data.entry.oid, name: seg });
|
||||
curTree = sub.data.entry.oid;
|
||||
} else break;
|
||||
} catch { break; }
|
||||
}
|
||||
if (stack.length > 1) setTreeStack(stack);
|
||||
})();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [urlDirPath, rootTreeOid]);
|
||||
|
||||
// Current tree OID from stack top
|
||||
const currentTreeOid = treeStack.length > 0 ? treeStack[treeStack.length - 1].oid : null;
|
||||
// Current directory path (skip root: repoName)
|
||||
const currentDirPath = treeStack.slice(1).map((s) => s.name).join("/");
|
||||
|
||||
// Fast tree entries (no last commit info)
|
||||
const { data: fastEntries } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "tree", currentTreeOid, currentDirPath, "fast"],
|
||||
queryFn: async () => {
|
||||
const res = await (client as any).gitTreeEntries(projectName, repoName, currentTreeOid!, { path: currentDirPath || undefined });
|
||||
return res.data.entries;
|
||||
},
|
||||
enabled: Boolean(currentTreeOid),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Full tree entries (with last commit info, may be async)
|
||||
const { data: fullEntries } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "tree", currentTreeOid, currentDirPath, "full"],
|
||||
queryFn: async () => {
|
||||
const res = await (client as any).gitTreeEntries(projectName, repoName, currentTreeOid!, { path: currentDirPath || undefined, last: true } as any);
|
||||
return res.data.entries;
|
||||
},
|
||||
enabled: Boolean(currentTreeOid),
|
||||
retry: false,
|
||||
// Backend returns entries immediately but enriches commit messages async.
|
||||
// Poll until enrichment is done (cached result includes commit messages).
|
||||
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) ?? [];
|
||||
|
||||
// Latest commit for selected branch
|
||||
const { data: latestCommit } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "latest-commit", selectedBranch],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitCommitHistory(projectName, repoName, { branch: selectedBranch, limit: 1 });
|
||||
return res.data.commits[0] ?? null;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Blob content for viewed file
|
||||
const { data: blobData, isLoading: blobLoading } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "blob", viewingFile?.oid],
|
||||
queryFn: async () => {
|
||||
const res = await (client as any).gitBlobInfo(projectName, repoName, viewingFile!.oid, { path: viewingFile!.name } as any);
|
||||
return res.data;
|
||||
},
|
||||
enabled: Boolean(viewingFile) && !viewingFile?.isBinary,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Blame lines
|
||||
const { data: blameLines } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "blame", viewingFile?.name],
|
||||
queryFn: async () => {
|
||||
const res: any = await client.gitBlameFile(projectName, repoName, { 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,
|
||||
});
|
||||
|
||||
// Commit info for blame (unique OIDs)
|
||||
const blameOids = blameLines ? [...new Set(blameLines.map((l) => l.commit_oid).filter(Boolean))] : [];
|
||||
const { data: blameCommits } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "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(projectName, repoName, oid);
|
||||
results[oid] = res.data.commit?.author?.name ?? "?";
|
||||
} catch { results[oid] = "?"; }
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
enabled: blameOids.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Language stats
|
||||
const { data: languages = [] } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "languages"],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitGetLanguages(projectName, repoName);
|
||||
return res.data;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const handleBranchSelect = useCallback((name: string) => {
|
||||
setSelectedBranch(name);
|
||||
setBranchOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleTreeNavigate = useCallback((entry: TreeEntryDto) => {
|
||||
setViewingFile(null);
|
||||
const path = [...treeStack.slice(1).map((s) => s.name), entry.name].join("/");
|
||||
setTreeStack((prev) => [...prev, { oid: entry.oid, name: entry.name }]);
|
||||
setSearchParams(path ? { path } : {}, { replace: true });
|
||||
}, [treeStack, setSearchParams]);
|
||||
|
||||
const handleFileView = useCallback((entry: TreeEntryDto) => {
|
||||
setViewingFile({ oid: entry.oid, name: entry.name, isBinary: entry.is_binary });
|
||||
}, []);
|
||||
|
||||
const handleBreadcrumbClick = useCallback((index: number) => {
|
||||
setViewingFile(null);
|
||||
const path = treeStack.slice(1, index + 1).map((s) => s.name).join("/");
|
||||
setTreeStack((prev) => prev.slice(0, index + 1));
|
||||
setSearchParams(path ? { path } : {}, { replace: true });
|
||||
}, [treeStack, setSearchParams]);
|
||||
|
||||
const entries = displayEntries;
|
||||
|
||||
// Stable skeleton widths — computed once at module level
|
||||
const [skeletonWidths] = useState(() => Array.from({ length: 12 }, () => 30 + Math.floor(Math.random() * 60)));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Top action bar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Branch dropdown */}
|
||||
<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) => (
|
||||
<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>
|
||||
|
||||
{/* Clone section */}
|
||||
<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>
|
||||
|
||||
{/* Archive buttons */}
|
||||
<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/${projectName}/repos/${repoName}/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/${projectName}/repos/${repoName}/git/archive/zip`}
|
||||
download
|
||||
>
|
||||
<Download className="size-3" /> ZIP
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language stats */}
|
||||
{languages.length > 0 && (
|
||||
<div className="flex items-center gap-0.5 h-1.5 rounded-full overflow-hidden bg-muted/30">
|
||||
{languages.map((lang) => (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Latest commit bar */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Content area: file tree or file viewer */}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
{/* Breadcrumb */}
|
||||
<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)}
|
||||
>
|
||||
× close
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File viewer */}
|
||||
{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>
|
||||
<p className="text-[11px] text-muted-foreground/60">This file cannot be displayed in the browser.</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 ? (
|
||||
/* File tree */
|
||||
<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>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/60">This directory contains no files.</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 className="mt-5 flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 font-mono text-[11px] text-muted-foreground">
|
||||
<code className="select-all">git push -u origin {selectedBranch}</code>
|
||||
<button
|
||||
className="ml-1 text-muted-foreground/60 hover:text-foreground transition-colors"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`git push -u origin ${selectedBranch}`);
|
||||
} catch { /* noop */ }
|
||||
}}
|
||||
title="Copy"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language legend */}
|
||||
{languages.length > 1 && (
|
||||
<div className="flex items-center gap-3 flex-wrap text-[11px] text-muted-foreground">
|
||||
{languages.map((lang) => (
|
||||
<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>
|
||||
);
|
||||
return <CodePanel workspace={projectName} repo={repoName} />;
|
||||
}
|
||||
@ -103,8 +103,8 @@ export default function CommitDetailPage() {
|
||||
{(diffData as any)?.stats && (
|
||||
<div className="mt-4 flex items-center gap-4 text-[12px]">
|
||||
<span className="font-mono text-muted-foreground">{ (diffData as any).stats.files_changed} files</span>
|
||||
<span className="flex items-center gap-1 text-green-600 font-mono"><Plus className="size-3" />{ (diffData as any).stats.insertions}</span>
|
||||
<span className="flex items-center gap-1 text-red-600 font-mono"><Minus className="size-3" />{ (diffData as any).stats.deletions}</span>
|
||||
<span className="flex items-center gap-1 text-success font-mono"><Plus className="size-3" />{ (diffData as any).stats.insertions}</span>
|
||||
<span className="flex items-center gap-1 text-destructive font-mono"><Minus className="size-3" />{ (diffData as any).stats.deletions}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -150,11 +150,11 @@ export default function CommitDetailPage() {
|
||||
<table className="w-full font-mono text-[12px] leading-relaxed">
|
||||
<tbody>
|
||||
{selectedDelta.lines.map((line: any, j: number) => (
|
||||
<tr className={cn(line.origin === "+" && "bg-green-950/20", line.origin === "-" && "bg-red-950/20")} key={j}>
|
||||
<tr className={cn(line.origin === "+" && "bg-success/10", line.origin === "-" && "bg-destructive/10")} key={j}>
|
||||
<td className="w-12 min-w-[48px] text-right px-2 text-muted-foreground/40 select-none border-r border-border">{line.old_lineno ?? ""}</td>
|
||||
<td className="w-12 min-w-[48px] text-right px-2 text-muted-foreground/40 select-none border-r border-border">{line.new_lineno ?? ""}</td>
|
||||
<td className="w-4 min-w-[16px] text-center text-muted-foreground/50 select-none">{line.origin}</td>
|
||||
<td className={cn("px-3 whitespace-pre", line.origin === "+" ? "text-green-400" : line.origin === "-" ? "text-red-400" : "text-foreground")}>{line.content}</td>
|
||||
<td className={cn("px-3 whitespace-pre", line.origin === "+" ? "text-success" : line.origin === "-" ? "text-destructive" : "text-foreground")}>{line.content}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -1,147 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useParams, useSearchParams } 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 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;
|
||||
import { useParams } from "react-router";
|
||||
import CommitsPanel from "@/components/repo/commits-panel";
|
||||
|
||||
export default function CommitsTab() {
|
||||
const { projectName = "", repoName = "" } = useParams();
|
||||
const [commitsSearchParams, setCommitsSearchParams] = useSearchParams();
|
||||
const offset = Number(commitsSearchParams.get("offset") ?? "0");
|
||||
const setOffset = (n: number) => {
|
||||
const next = new URLSearchParams(commitsSearchParams);
|
||||
if (n <= 0) next.delete("offset"); else next.set("offset", String(n));
|
||||
setCommitsSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const { data: repoData } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitGetRepo(projectName, repoName);
|
||||
return res.data;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const defaultBranch = repoData?.default_branch ?? "main";
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedBranch, setSelectedBranch] = useState(defaultBranch);
|
||||
|
||||
const { data: commits = [], isLoading } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "commits", selectedBranch, offset],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitCommitHistory(projectName, repoName, { branch: selectedBranch, skip: offset, limit: LIMIT });
|
||||
return res.data.commits;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "branches"],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitListBranches(projectName, repoName);
|
||||
return res.data.branches;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const filtered = search
|
||||
? commits.filter((c) => c.message.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) => (
|
||||
<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) => (
|
||||
<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={`/${projectName}/repo/${repoName}/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>
|
||||
);
|
||||
return <CommitsPanel workspace={projectName} repo={repoName} />;
|
||||
}
|
||||
@ -1,67 +1,7 @@
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { client } from "@/client";
|
||||
import { Users, GitCommitHorizontal } from "lucide-react";
|
||||
import PaginationBar from "./pagination";
|
||||
|
||||
const LIMIT = 20;
|
||||
import { useParams } from "react-router";
|
||||
import ContributorsPanel from "@/components/repo/contributors-panel";
|
||||
|
||||
export default function ContributorsTab() {
|
||||
const { projectName = "", repoName = "" } = useParams();
|
||||
const [contribSearchParams, setContribSearchParams] = useSearchParams();
|
||||
const offset = Number(contribSearchParams.get("offset") ?? "0");
|
||||
const setOffset = (n: number) => {
|
||||
const next = new URLSearchParams(contribSearchParams);
|
||||
if (n <= 0) next.delete("offset"); else next.set("offset", String(n));
|
||||
setContribSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const { data: contributors = [], isLoading } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "contributors", offset],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitListContributors(projectName, repoName, { params: { offset, limit: LIMIT } } as any);
|
||||
return res.data;
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const sorted = [...contributors].sort((a, b) => 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) => (
|
||||
<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>
|
||||
);
|
||||
return <ContributorsPanel workspace={projectName} repo={repoName} />;
|
||||
}
|
||||
@ -101,7 +101,6 @@ export default function RepoLayout() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["repo", projectName, repoName, "watch"] }),
|
||||
});
|
||||
|
||||
// Check if README exists
|
||||
const { data: readme } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "readme"],
|
||||
queryFn: async () => {
|
||||
@ -147,24 +146,24 @@ export default function RepoLayout() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="font-heading text-lg font-bold text-foreground">{repo.name}</h1>
|
||||
{repo.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 className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<Lock className="size-3" />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 className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<Globe className="size-3" />Public
|
||||
</span>
|
||||
)}
|
||||
{repo.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 className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<Archive className="size-3" />Archived
|
||||
</span>
|
||||
)}
|
||||
{repo.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>
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">Mirror</span>
|
||||
)}
|
||||
{repo.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>
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">Template</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -172,7 +171,7 @@ export default function RepoLayout() {
|
||||
<p className="mt-1 text-[13px] text-muted-foreground max-w-2xl">{repo.description}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-3 font-mono text-[11px] text-muted-foreground flex-wrap">
|
||||
<div className="mt-3 flex items-center gap-3 text-[12px] text-muted-foreground flex-wrap">
|
||||
<span><span className="text-foreground/60">default branch:</span> {repo.default_branch}</span>
|
||||
<span className="text-muted-foreground/30">|</span>
|
||||
<span>{formatSize(repo.size_bytes)}</span>
|
||||
@ -180,33 +179,33 @@ export default function RepoLayout() {
|
||||
<span>created {formatDate(repo.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")}
|
||||
className={cn("inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30", starData?.starred ? "border-primary/30 bg-primary/10 text-primary" : "border-border bg-card text-muted-foreground hover:bg-muted/50 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>}
|
||||
{typeof starData?.count === "number" && <span className="text-[11px] tabular-nums opacity-70">{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")}
|
||||
className={cn("inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30", watchData?.watching ? "border-primary/30 bg-primary/10 text-primary" : "border-border bg-card text-muted-foreground hover:bg-muted/50 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>}
|
||||
{typeof watchData?.count === "number" && <span className="text-[11px] tabular-nums opacity-70">{watchData.count}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab nav */}
|
||||
<nav className="mt-6 flex items-center gap-0 overflow-x-auto border-b border-border">
|
||||
<nav className="mt-6 flex items-center gap-1 overflow-x-auto rounded-xl border border-border bg-muted/30 p-1">
|
||||
{allTabs.map((tab) => (
|
||||
<NavLink
|
||||
className={({ isActive }) => cn(
|
||||
"inline-flex shrink-0 items-center px-3 h-8 text-[13px] font-medium border-b-2 border-transparent transition-colors -mb-px",
|
||||
isActive ? "text-foreground border-primary" : "text-muted-foreground hover:text-foreground hover:border-muted-foreground/30",
|
||||
"inline-flex h-8 shrink-0 items-center rounded-lg px-3 text-[13px] font-medium transition-colors",
|
||||
isActive ? "bg-card text-foreground shadow-sm ring-1 ring-border" : "text-muted-foreground hover:bg-background/60 hover:text-foreground",
|
||||
)}
|
||||
end
|
||||
key={tab.to}
|
||||
|
||||
@ -189,9 +189,9 @@ export default function PullDetailPage() {
|
||||
{pr.merged_at ? (
|
||||
<GitMerge className="size-5 text-purple-600 shrink-0" />
|
||||
) : isOpen ? (
|
||||
<Circle className="size-5 text-green-600 shrink-0" />
|
||||
<Circle className="size-5 text-success shrink-0" />
|
||||
) : (
|
||||
<CircleDot className="size-5 text-red-600 shrink-0" />
|
||||
<CircleDot className="size-5 text-destructive shrink-0" />
|
||||
)}
|
||||
<h1 className="font-heading text-[15px] font-bold text-foreground">{pr.title}</h1>
|
||||
<span className="font-mono text-[12px] text-muted-foreground/60"># {pr.number}</span>
|
||||
@ -201,7 +201,7 @@ export default function PullDetailPage() {
|
||||
<AuthorAvatar author={pr.author} />
|
||||
<span className="font-medium text-foreground">{pr.author.display_name ?? pr.author.username}</span>
|
||||
<span>{pr.merged_at ? "merged" : pr.state === "closed" ? "closed" : "opened"} {formatDate(pr.created_at)}</span>
|
||||
{pr.draft && <span className="rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px]">Draft</span>}
|
||||
{pr.draft && <span className="rounded-md border border-border bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">Draft</span>}
|
||||
<span className="font-mono text-[11px] text-muted-foreground/60">{pr.source_branch} → {pr.target_branch}</span>
|
||||
</div>
|
||||
|
||||
@ -279,8 +279,8 @@ export default function PullDetailPage() {
|
||||
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground mb-2">Files changed</p>
|
||||
<div className="flex items-center gap-4 text-[12px] mb-3">
|
||||
<span className="font-mono text-muted-foreground">{ (diffData as any).stats.files_changed} files</span>
|
||||
<span className="flex items-center gap-1 text-green-600 font-mono"><Plus className="size-3" />{ (diffData as any).stats.insertions}</span>
|
||||
<span className="flex items-center gap-1 text-red-600 font-mono"><Minus className="size-3" />{ (diffData as any).stats.deletions}</span>
|
||||
<span className="flex items-center gap-1 text-success font-mono"><Plus className="size-3" />{ (diffData as any).stats.insertions}</span>
|
||||
<span className="flex items-center gap-1 text-destructive font-mono"><Minus className="size-3" />{ (diffData as any).stats.deletions}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(diffData as any).deltas.map((delta: any, i: number) => {
|
||||
@ -296,11 +296,11 @@ export default function PullDetailPage() {
|
||||
<table className="w-full font-mono text-[11px] leading-relaxed">
|
||||
<tbody>
|
||||
{delta.lines.map((line: any, j: number) => (
|
||||
<tr className={cn(line.origin === "+" && "bg-green-950/20", line.origin === "-" && "bg-red-950/20")} key={j}>
|
||||
<tr className={cn(line.origin === "+" && "bg-success/10", line.origin === "-" && "bg-destructive/10")} key={j}>
|
||||
<td className="w-10 text-right px-1.5 text-muted-foreground/40 select-none border-r border-border">{line.old_lineno ?? ""}</td>
|
||||
<td className="w-10 text-right px-1.5 text-muted-foreground/40 select-none border-r border-border">{line.new_lineno ?? ""}</td>
|
||||
<td className="w-3 text-center text-muted-foreground/50 select-none">{line.origin}</td>
|
||||
<td className={cn("px-2 whitespace-pre", line.origin === "+" ? "text-green-400" : line.origin === "-" ? "text-red-400" : "text-foreground")}>{line.content}</td>
|
||||
<td className={cn("px-2 whitespace-pre", line.origin === "+" ? "text-success" : line.origin === "-" ? "text-destructive" : "text-foreground")}>{line.content}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -358,9 +358,9 @@ export default function PullDetailPage() {
|
||||
{pr.merged_at ? (
|
||||
<span className="text-[12px] text-purple-600 font-heading font-medium">Merged</span>
|
||||
) : pr.state === "open" ? (
|
||||
<span className="text-[12px] text-green-600 font-heading font-medium">Open</span>
|
||||
<span className="text-[12px] text-success font-heading font-medium">Open</span>
|
||||
) : (
|
||||
<span className="text-[12px] text-red-600 font-heading font-medium">Closed</span>
|
||||
<span className="text-[12px] text-destructive font-heading font-medium">Closed</span>
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
|
||||
@ -61,9 +61,9 @@ function PullStateIcon({ pr }: { pr: PullRequestResponse }) {
|
||||
return <GitMerge className="size-[14px] text-purple-600 shrink-0 mt-0.5" />;
|
||||
}
|
||||
if (pr.state === "closed") {
|
||||
return <CircleDot className="size-[14px] text-red-600 shrink-0 mt-0.5" />;
|
||||
return <CircleDot className="size-[14px] text-destructive shrink-0 mt-0.5" />;
|
||||
}
|
||||
return <Circle className="size-[14px] text-green-600 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 }) {
|
||||
@ -78,7 +78,7 @@ function PullRow({ pr, projectName, repoName }: { pr: PullRequestResponse; proje
|
||||
<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-sm border border-border px-1 py-0.5 font-mono text-[10px] text-muted-foreground leading-none">
|
||||
<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>
|
||||
)}
|
||||
@ -132,16 +132,16 @@ export default function PullsTab() {
|
||||
|
||||
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">
|
||||
<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-green-600" />, count: openCount },
|
||||
{ key: "closed", icon: <CircleDot className="size-3 text-red-600" />, count: closedCount },
|
||||
{ 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 px-2.5 py-1 text-[12px] font-medium transition-colors rounded-sm", stateFilter === tab.key ? "text-foreground bg-muted/70" : "text-muted-foreground hover:text-foreground")}
|
||||
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); }}
|
||||
>
|
||||
@ -152,12 +152,12 @@ export default function PullsTab() {
|
||||
))}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Link className="h-7 px-2 inline-flex items-center text-[12px] font-medium rounded-sm border border-border text-muted-foreground hover:text-foreground transition-colors" to={`/${projectName}/repo/${repoName}/pulls/new`}>
|
||||
<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 className="h-7 w-52 rounded-sm border-border bg-card pl-7 text-[12px]" placeholder="Filter PRs..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<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>
|
||||
|
||||
@ -239,7 +239,7 @@ export default function RepoSettingsTab() {
|
||||
<span className="font-heading text-[13px] font-semibold text-foreground">{p.pattern}</span>
|
||||
{p.require_pull_request && <span className="font-mono text-[10px] text-muted-foreground">requires PR</span>}
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{p.required_approvals > 0 ? `${p.required_approvals} approval(s)` : ""}</span>
|
||||
<Button className="size-7 text-destructive hover:text-destructive ml-auto" onClick={() => deleteProtect.mutate(p.id)} size="icon" variant="ghost">
|
||||
<Button aria-label="Delete branch protection rule" className="ml-auto size-8 text-destructive hover:text-destructive" onClick={() => deleteProtect.mutate(p.id)} size="icon" variant="ghost">
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@ -254,7 +254,7 @@ export default function RepoSettingsTab() {
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-3 rounded-md border border-red-900/30 p-4">
|
||||
<h2 className="font-heading text-[15px] font-bold text-red-600 flex items-center gap-2"><Trash2 className="size-4" /> Danger Zone</h2>
|
||||
<h2 className="font-heading text-[15px] font-bold text-destructive flex items-center gap-2"><Trash2 className="size-4" /> Danger Zone</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-foreground">Transfer ownership</p>
|
||||
|
||||
@ -1,114 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
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 PaginationBar from "./pagination";
|
||||
|
||||
const LIMIT = 20;
|
||||
import { useParams } from "react-router";
|
||||
import TagsPanel from "@/components/repo/tags-panel";
|
||||
|
||||
export default function TagsTab() {
|
||||
const { projectName = "", repoName = "" } = useParams();
|
||||
const qc = useQueryClient();
|
||||
const [tagsSearchParams, setTagsSearchParams] = useSearchParams();
|
||||
const offset = Number(tagsSearchParams.get("offset") ?? "0");
|
||||
const setOffset = (n: number) => {
|
||||
const next = new URLSearchParams(tagsSearchParams);
|
||||
if (n <= 0) next.delete("offset"); else next.set("offset", String(n));
|
||||
setTagsSearchParams(next, { replace: true });
|
||||
};
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newOid, setNewOid] = useState("");
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const { data: tags = [], isLoading } = useQuery({
|
||||
queryKey: ["repo", projectName, repoName, "tags", offset],
|
||||
queryFn: async () => {
|
||||
const res = await client.gitListTags(projectName, repoName, { params: { offset, limit: LIMIT } } as any);
|
||||
return (res.data as any).tags ?? [];
|
||||
},
|
||||
enabled: Boolean(projectName) && Boolean(repoName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const createTag = useMutation({
|
||||
mutationFn: async () => {
|
||||
await client.gitInitTag(projectName, repoName, { name: newName, oid: newOid || undefined, message: newMessage || undefined } as any);
|
||||
},
|
||||
onSuccess: () => { setShowCreate(false); setNewName(""); setNewOid(""); setNewMessage(""); qc.invalidateQueries({ queryKey: ["repo", projectName, repoName, "tags"] }); },
|
||||
});
|
||||
|
||||
const deleteTag = useMutation({
|
||||
mutationFn: async (name: string) => { await client.gitDeleteTag(projectName, repoName, { name } as any); },
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["repo", projectName, repoName, "tags"] }),
|
||||
});
|
||||
|
||||
const sorted = [...tags].sort((a, b) => 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) => (
|
||||
<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>
|
||||
);
|
||||
return <TagsPanel workspace={projectName} repo={repoName} />;
|
||||
}
|
||||
@ -78,10 +78,10 @@ export default function WebhooksTab() {
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="rounded-md border border-border p-4 space-y-4">
|
||||
<div className="space-y-4 rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-heading text-[13px] font-semibold text-foreground">{editingId ? "Edit webhook" : "New webhook"}</span>
|
||||
<Button className="size-6 text-muted-foreground" onClick={resetForm} size="icon" variant="ghost"><X className="size-3" /></Button>
|
||||
<Button aria-label="Close webhook form" className="size-8 text-muted-foreground" onClick={resetForm} size="icon" variant="ghost"><X className="size-3" /></Button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium text-foreground">Payload URL</label>
|
||||
@ -95,7 +95,7 @@ export default function WebhooksTab() {
|
||||
<label className="text-[13px] font-medium text-foreground">Events</label>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{EVENT_OPTIONS.map((event) => (
|
||||
<button className={`px-2 py-1 text-[11px] font-mono rounded-sm border transition-colors ${events.includes(event) ? "border-primary bg-primary/5 text-primary" : "border-border text-muted-foreground hover:text-foreground"}`} key={event} onClick={() => toggleEvent(event)} type="button">
|
||||
<button className={`rounded-lg border px-2 py-1 text-[11px] font-medium transition-colors ${events.includes(event) ? "border-primary/30 bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-muted/40 hover:text-foreground"}`} key={event} onClick={() => toggleEvent(event)} type="button">
|
||||
{event}
|
||||
</button>
|
||||
))}
|
||||
@ -115,7 +115,7 @@ export default function WebhooksTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card shadow-sm">
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-2">{Array.from({ length: 2 }).map((_, i) => <div className="h-6 animate-pulse rounded bg-muted/50 my-1.5" key={i} />)}</div>
|
||||
) : webhooks.length > 0 ? (
|
||||
@ -126,14 +126,14 @@ export default function WebhooksTab() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-heading text-[13px] font-semibold text-foreground truncate block">{wh.url}</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`inline-block size-1.5 rounded-full ${wh.active ? "bg-green-500" : "bg-muted-foreground"}`} />
|
||||
<span className={`inline-block size-1.5 rounded-full ${wh.active ? "bg-success" : "bg-muted-foreground"}`} />
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{wh.events.join(", ")}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">· {formatDate(wh.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="size-7 text-muted-foreground hover:text-foreground" onClick={() => startEdit(wh)} size="icon" variant="ghost"><Pencil className="size-3" /></Button>
|
||||
<Button className="size-7 text-muted-foreground hover:text-foreground" onClick={() => setShowDeliveries(showDeliveries === wh.id ? null : wh.id)} size="icon" variant="ghost"><History className="size-3" /></Button>
|
||||
<Button className="size-7 text-destructive hover:text-destructive" onClick={() => deleteWebhook.mutate(wh.id)} size="icon" variant="ghost"><Trash2 className="size-3.5" /></Button>
|
||||
<Button aria-label="Edit webhook" className="size-8 text-muted-foreground hover:text-foreground" onClick={() => startEdit(wh)} size="icon" variant="ghost"><Pencil className="size-3" /></Button>
|
||||
<Button aria-label="Toggle delivery history" className="size-8 text-muted-foreground hover:text-foreground" onClick={() => setShowDeliveries(showDeliveries === wh.id ? null : wh.id)} size="icon" variant="ghost"><History className="size-3" /></Button>
|
||||
<Button aria-label="Delete webhook" className="size-8 text-destructive hover:text-destructive" onClick={() => deleteWebhook.mutate(wh.id)} size="icon" variant="ghost"><Trash2 className="size-3.5" /></Button>
|
||||
</div>
|
||||
{showDeliveries === wh.id && (
|
||||
<div className="border-b border-border bg-muted/20 px-6 py-3">
|
||||
@ -141,7 +141,7 @@ export default function WebhooksTab() {
|
||||
{deliveries.length > 0 ? (
|
||||
<div className="space-y-1">{deliveries.slice(0, 10).map((d: any, i: number) => (
|
||||
<div className="flex items-center gap-2 text-[11px] font-mono text-muted-foreground" key={i}>
|
||||
<span className={`inline-block size-1.5 rounded-full ${d.status === "success" ? "bg-green-500" : "bg-red-500"}`} />
|
||||
<span className={`inline-block size-1.5 rounded-full ${d.status === "success" ? "bg-success" : "bg-destructive"}`} />
|
||||
<span>{d.status ?? d.response_status ?? "?"}</span>
|
||||
{d.timestamp && <span>{formatDate(d.timestamp)}</span>}
|
||||
{d.http_status && <span>HTTP {d.http_status}</span>}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user