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") ?? "";