diff --git a/src/components/repo/branches-panel.tsx b/src/components/repo/branches-panel.tsx new file mode 100644 index 0000000..b2d43fb --- /dev/null +++ b/src/components/repo/branches-panel.tsx @@ -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(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 ( +
+
+
+ + setSearch(e.target.value)} /> +
+ +
+ + {showCreate && ( +
+ setNewBranchName(e.target.value)} /> + setNewBranchSource(e.target.value)} /> + + +
+ )} + +
+ {isLoading ? ( +
{Array.from({ length: 3 }).map((_, i) =>
)}
+ ) : filtered.length > 0 ? ( +
+ {defaultBranches.map((b: any) => )} + {otherBranches.length > 0 && defaultBranches.length > 0 && ( +
+ Other branches +
+ )} + {otherBranches.map((b: any) => )} +
+ ) : ( +
+
+ +
+

No branches yet

+

+ {search ? `No branches matching "${search}"` : "Create a branch to start working on your project."} +

+ {!search && ( + + )} +
+ )} +
+ + {/* Branch comparison */} + +
+ ); +} + +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 ( +
+
+ Compare branches +
+
+
+ + + +
+ {diffResult?.stats && ( +
+ {diffResult.stats.files_changed} files + +{diffResult.stats.insertions} + -{diffResult.stats.deletions} +
+ )} +
+
+ ); +} + +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 ( +
+ + {isEditing ? ( +
+ setRenameValue(e.target.value)} autoFocus /> + + +
+ ) : ( + <> + {branch.name} + {isDefault && Default} +
+ {branch.is_head && HEAD} + {branch.oid.slice(0, 7)} + {!isDefault && ( + + )} + {!isDefault && ( + + )} +
+ + )} +
+ ); +} diff --git a/src/components/repo/code-panel.tsx b/src/components/repo/code-panel.tsx new file mode 100644 index 0000000..8202f58 --- /dev/null +++ b/src/components/repo/code-panel.tsx @@ -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 = { + 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 ; + if (kind === "lfs_pointer") return ; + return ; +} + +function AuthorAvatar({ author }: { author: { name: string } }) { + return ( + + {workspaceInitial(author.name)} + + ); +} + +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 = {}; + 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 ( +
+ {/* Top action bar */} +
+
+ + {branchOpen && ( + <> +
setBranchOpen(false)} /> +
+
Switch branch
+
+ {branchList && branchList.length > 0 ? branchList.map((b: any) => ( + + )) : ( +
No branches found
+ )} +
+
+ + )} +
+ +
+
+ + +
+ {cloneUrl} + +
+ + +
+ TAR + + ZIP +
+
+ + {languages.length > 0 && ( +
+ {languages.map((lang: any) => ( +
+ ))} +
+ )} + + {latestCommit && ( +
+ +
+ + {latestCommit.summary} +
+ {latestCommit.author?.time_secs ? formatTimeAgo(latestCommit.author.time_secs) : ""} + {latestCommit.oid.slice(0, 7)} +
+ )} + +
+
+ {treeStack.map((item, i) => ( + + ))} + {viewingFile && ( + <> + + {viewingFile.name} + + + + )} +
+ + {viewingFile ? ( + viewingFile.isBinary ? ( +

Binary file

+ ) : blobLoading ? ( +
{skeletonWidths.map((w, i) =>
)}
+ ) : blobData ? ( +
+ {showBlame && blameLines ? ( + + + {blameLines.map((line, i) => ( + + + {line.commit_oid && } + + + ))} + +
{line.line_no}{blameCommits?.[line.commit_oid] ?? line.commit_oid.slice(0, 6)}{line.content}
+ ) : ( +
{decodeBase64((blobData as any).blob)}
+ )} +
+ ) : ( +

Could not load file content

+ ) + ) : entries.length > 0 ? ( +
+ {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) => ( +
{ if (entry.kind === "tree") handleTreeNavigate(entry); else handleFileView(entry); }}> + + {entry.name} + {entry.is_lfs && LFS} +
+ {entry.last_commit_message && {entry.last_commit_message}} + {entry.last_commit_author_name && {entry.last_commit_author_name}} + {entry.last_commit_time && {formatTimeAgoEntry(entry.last_commit_time)}} +
+
+ ))} +
+ ) : currentTreeOid ? ( +
+
+

Empty directory

+
+ ) : ( +
+
+

No content yet

+

This repository is empty. Push your first commit to get started.

+
+ )} +
+ + {languages.length > 1 && ( +
+ {languages.map((lang: any) => ( + + + {lang.language} {lang.percentage.toFixed(1)}% + + ))} +
+ )} +
+ ); +} diff --git a/src/components/repo/commits-panel.tsx b/src/components/repo/commits-panel.tsx new file mode 100644 index 0000000..0cc924b --- /dev/null +++ b/src/components/repo/commits-panel.tsx @@ -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 ( + + {workspaceInitial(author.name)} + + ); +} + +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 ( +
+
+ +
+ + setSearch(e.target.value)} /> +
+
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) =>
)} +
+ ) : filtered.length > 0 ? ( + <> +
+ {filtered.map((commit: any) => ( + + +
+ {commit.summary} +
+
+ + {commit.author?.name} + {commit.author?.time_secs ? formatTimeAgo(commit.author.time_secs) : ""} + {commit.oid.slice(0, 7)} +
+ + ))} +
+ = LIMIT} onPrev={() => setOffset(Math.max(0, offset - LIMIT))} onNext={() => setOffset(offset + LIMIT)} /> + + ) : ( +
+
+ +
+

+ {search ? "No matching commits" : "No commits yet"} +

+

+ {search ? `No commits match "${search}"` : "Push your first commit to this branch to get started."} +

+
+ )} +
+
+ ); +} diff --git a/src/components/repo/contributors-panel.tsx b/src/components/repo/contributors-panel.tsx new file mode 100644 index 0000000..fb5082c --- /dev/null +++ b/src/components/repo/contributors-panel.tsx @@ -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 ( +
+
+
+ Contributors +
+ {isLoading ? ( +
{Array.from({ length: 5 }).map((_, i) =>
)}
+ ) : sorted.length > 0 ? ( + <> +
+ {sorted.map((c: any) => ( +
+ +
+ {c.name} + {c.email} +
+
+ + {c.commit_count} commit{c.commit_count !== 1 ? "s" : ""} +
+
+ ))} +
+ = LIMIT} onPrev={() => setOffset(Math.max(0, offset - LIMIT))} onNext={() => setOffset(offset + LIMIT)} /> + + ) : ( +
+ +

No contributors found

+
+ )} +
+
+ ); +} diff --git a/src/components/repo/pagination.tsx b/src/components/repo/pagination.tsx new file mode 100644 index 0000000..3989ad9 --- /dev/null +++ b/src/components/repo/pagination.tsx @@ -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 ( +
+ Page {page} +
+ + +
+
+ ); +} diff --git a/src/components/repo/repo-view.tsx b/src/components/repo/repo-view.tsx new file mode 100644 index 0000000..b1471a0 --- /dev/null +++ b/src/components/repo/repo-view.tsx @@ -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 ( +
+
+
+
+
+ ); + } + + if (!repoData) { + return ( +
+
+

Repository not found

+

This repository doesn't exist or you don't have permission to view it.

+
+ ); + } + + const tabs = [ + ...(readme?.html ? [{ id: "readme", label: "README", icon: }] : []), + { id: "code", label: "Code", icon: }, + { id: "commits", label: "Commits", icon: }, + { id: "branches", label: "Branches", icon: }, + { id: "tags", label: "Tags", icon: }, + { id: "contributors", label: "Contributors", icon: }, + ]; + + return ( +
+ {/* Header */} +
+

{repoData.name}

+ {repoData.visibility === "private" ? ( + Private + ) : ( + Public + )} + {repoData.is_archived && Archived} + {repoData.is_mirror && Mirror} + {repoData.is_template && Template} +
+ + {repoData.description &&

{repoData.description}

} + +
+ default branch: {repoData.default_branch} + | + {formatSize(repoData.size_bytes)} + | + created {formatDate(repoData.created_at)} +
+ + +
+
+ + {/* Tab nav */} + + + {/* Tab content */} +
+ {activeTab === "code" && } + {activeTab === "readme" && readme?.html && ( +
+ )} + {activeTab === "commits" && } + {activeTab === "branches" && } + {activeTab === "tags" && } + {activeTab === "contributors" && } +
+
+ ); +} diff --git a/src/components/repo/tags-panel.tsx b/src/components/repo/tags-panel.tsx new file mode 100644 index 0000000..39c15a6 --- /dev/null +++ b/src/components/repo/tags-panel.tsx @@ -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 ( +
+
+

{tags.length} tag{tags.length !== 1 ? "s" : ""}

+ +
+ + {showCreate && ( +
+ setNewName(e.target.value)} /> + setNewOid(e.target.value)} /> + setNewMessage(e.target.value)} /> +
+ + +
+
+ )} + +
+ {isLoading ? ( +
{Array.from({ length: 3 }).map((_, i) =>
)}
+ ) : sorted.length > 0 ? ( + <> +
+ {sorted.map((tag: any) => ( +
+ + {tag.name} + {tag.is_annotated && annotated} +
+ {tag.oid.slice(0, 7)} + {tag.tagger && {tag.tagger}} + +
+
+ ))} +
+ = LIMIT} onPrev={() => setOffset(Math.max(0, offset - LIMIT))} onNext={() => setOffset(offset + LIMIT)} /> + + ) : ( +
+
+ +
+

No tags yet

+

+ Tags mark specific points in repository history, typically used for releases. +

+ +
+ )} +
+
+ ); +} diff --git a/src/components/repo/use-offset.ts b/src/components/repo/use-offset.ts new file mode 100644 index 0000000..ea72672 --- /dev/null +++ b/src/components/repo/use-offset.ts @@ -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]; +}