From f8d04456b995de2cf281e6031578175513201615 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 12 May 2026 09:19:08 -0700 Subject: [PATCH] feat(web): add repo detail page with refs, channel link, and loading states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display branch/tag refs from kind:30618 events, HEAD commit SHA, channel link from sprout-channel tag, and proper loading/empty states. No new backend APIs — leverages existing Nostr event types. Co-Authored-By: Claude Opus 4.6 --- web/src/features/repos/ui/RepoDetailPage.tsx | 19 ++++++ web/src/features/repos/ui/RepoRefsSection.tsx | 54 +++++++++++++++ web/src/features/repos/use-repo-refs.ts | 68 +++++++++++++++++++ web/src/features/repos/use-repos.ts | 7 +- 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 web/src/features/repos/ui/RepoRefsSection.tsx create mode 100644 web/src/features/repos/use-repo-refs.ts diff --git a/web/src/features/repos/ui/RepoDetailPage.tsx b/web/src/features/repos/ui/RepoDetailPage.tsx index 3b5142e605..9f133f60ec 100644 --- a/web/src/features/repos/ui/RepoDetailPage.tsx +++ b/web/src/features/repos/ui/RepoDetailPage.tsx @@ -4,6 +4,7 @@ import { Check, Copy, ExternalLink, + MessageSquare, Users, } from "lucide-react"; import { useEffect, useState } from "react"; @@ -12,9 +13,11 @@ import { toast } from "sonner"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; +import { useRepoRefs } from "../use-repo-refs"; import { useRepo } from "../use-repos"; import { ConnectButton } from "./ConnectButton"; import { PubkeyAvatar } from "./PubkeyAvatar"; +import { RepoRefsSection } from "./RepoRefsSection"; function relativeTime(unix: number): string { const now = Date.now(); @@ -85,6 +88,7 @@ function DetailSkeleton() { export function RepoDetailPage() { const { repoId } = useParams({ from: "/repos/$repoId" }); const { data: repo, isLoading, error } = useRepo(repoId); + const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId); useEffect(() => { if (error) { @@ -146,6 +150,9 @@ export function RepoDetailPage() {

+ {/* Refs & HEAD */} + + {/* Clone URLs */} {repo.cloneUrls.length > 0 && (
@@ -186,6 +193,18 @@ export function RepoDetailPage() {
+ {/* Channel link */} + {repo.channelId && ( +
+ +
+ )} + {/* Open in Sprout CTA */}

diff --git a/web/src/features/repos/ui/RepoRefsSection.tsx b/web/src/features/repos/ui/RepoRefsSection.tsx new file mode 100644 index 0000000000..11b38acd75 --- /dev/null +++ b/web/src/features/repos/ui/RepoRefsSection.tsx @@ -0,0 +1,54 @@ +import { GitBranch, Hash, Tag } from "lucide-react"; + +import { Badge } from "@/shared/ui/badge"; +import type { RepoRefs } from "../use-repo-refs"; + +export function RepoRefsSection({ + refs, + isLoading, +}: { + refs: RepoRefs | undefined; + isLoading: boolean; +}) { + if (isLoading) return null; + + const hasRefs = refs && (refs.branches.length > 0 || refs.tags.length > 0); + + return ( +

+ {hasRefs ? ( +
+ {refs.head && ( + <> +
+ + + {refs.head.ref} + + {refs.head.sha && ( + + + {refs.head.sha.slice(0, 7)} + + )} +
+ · + + )} + + + {refs.branches.length}{" "} + {refs.branches.length === 1 ? "branch" : "branches"} + + · + + + {refs.tags.length} {refs.tags.length === 1 ? "tag" : "tags"} + +
+ ) : ( +

No commits yet

+ )} +
+ ); +} diff --git a/web/src/features/repos/use-repo-refs.ts b/web/src/features/repos/use-repo-refs.ts new file mode 100644 index 0000000000..dc21be2791 --- /dev/null +++ b/web/src/features/repos/use-repo-refs.ts @@ -0,0 +1,68 @@ +import { useQuery } from "@tanstack/react-query"; +import { queryEvents, type NostrEvent } from "@/shared/lib/nostr-client"; +import { relayWsUrl } from "@/shared/lib/relay-url"; +import { dedup } from "./use-repos"; + +export interface RepoRefs { + branches: string[]; + tags: string[]; + head: { ref: string; sha: string } | null; +} + +function parseRefs(events: NostrEvent[]): RepoRefs { + const latest = dedup(events); + const branches: string[] = []; + const tags: string[] = []; + let head: RepoRefs["head"] = null; + + for (const event of latest) { + for (const tag of event.tags) { + const [name, value] = tag; + if (!name || !value) continue; + + if (name === "HEAD" && value.startsWith("ref: refs/heads/")) { + // HEAD points to a branch ref — find its SHA from a matching branch tag + const branchName = value.replace("ref: refs/heads/", ""); + head = { ref: branchName, sha: "" }; + } else if (name.startsWith("refs/heads/")) { + branches.push(name.replace("refs/heads/", "")); + } else if (name.startsWith("refs/tags/")) { + tags.push(name.replace("refs/tags/", "")); + } + } + } + + // Resolve HEAD SHA from the matching branch + if (head) { + for (const event of latest) { + for (const tag of event.tags) { + if (tag[0] === `refs/heads/${head.ref}` && tag[1]) { + head = { ref: head.ref, sha: tag[1] }; + break; + } + } + if (head.sha) break; + } + } + + return { branches, tags, head }; +} + +async function fetchRepoRefs(repoId: string): Promise { + // TODO: Filter by `authors: [relayPubkey]` once the relay's own pubkey is + // exposed to the client. Without this, a user with ReposWrite permission + // could publish fake kind:30618 events with spoofed refs. + const events = await queryEvents(relayWsUrl(), { + kinds: [30618], + "#d": [repoId], + }); + return parseRefs(events); +} + +export function useRepoRefs(repoId: string) { + return useQuery({ + queryKey: ["repo-refs", repoId], + queryFn: () => fetchRepoRefs(repoId), + staleTime: 60_000, + }); +} diff --git a/web/src/features/repos/use-repos.ts b/web/src/features/repos/use-repos.ts index 6a647a2155..d6f43df913 100644 --- a/web/src/features/repos/use-repos.ts +++ b/web/src/features/repos/use-repos.ts @@ -8,13 +8,14 @@ export interface Repo { description: string; cloneUrls: string[]; webUrl: string | null; + channelId: string | null; owner: string; contributors: string[]; createdAt: number; } /** Extract the first value for a given tag name from a Nostr event. */ -function getTag(event: NostrEvent, name: string): string | undefined { +export function getTag(event: NostrEvent, name: string): string | undefined { return event.tags.find((t) => t[0] === name)?.[1]; } @@ -29,6 +30,7 @@ function eventToRepo(event: NostrEvent): Repo { const description = getTag(event, "description") || event.content || ""; const cloneUrls = getAllTags(event, "clone"); const webUrl = getTag(event, "web") ?? null; + const channelId = getTag(event, "sprout-channel") ?? null; const contributors = getAllTags(event, "p"); const owner = event.pubkey; @@ -38,6 +40,7 @@ function eventToRepo(event: NostrEvent): Repo { description, cloneUrls, webUrl, + channelId, owner, contributors, createdAt: event.created_at, @@ -45,7 +48,7 @@ function eventToRepo(event: NostrEvent): Repo { } /** Deduplicate NIP-33 parameterized replaceable events, keeping the latest per (pubkey, kind, d-tag). */ -function dedup(events: NostrEvent[]): NostrEvent[] { +export function dedup(events: NostrEvent[]): NostrEvent[] { const best = new Map(); for (const e of events) { const d = getTag(e, "d") ?? "";