feat: add repository state (kind 30618) visualization

Implement renderers for NIP-34 repository state announcements:
- Add helper functions in nip34-helpers.ts to parse HEAD refs, branches, and tags
- Create RepositoryStateRenderer for compact feed view showing push notifications
- Create RepositoryStateDetailRenderer for detailed view with all refs
- Register both renderers in the kind registry

Feed view shows: "pushed <commit> to <branch> in <repo>"
Detail view shows: HEAD info, all branches, all tags with copyable commit hashes
This commit is contained in:
Claude
2025-12-21 12:35:23 +00:00
parent eb74306127
commit 1519e30e5c
4 changed files with 285 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
import { useMemo } from "react";
import { GitBranch, GitCommit, Tag, Copy, CopyCheck } from "lucide-react";
import { useCopy } from "@/hooks/useCopy";
import type { NostrEvent } from "@/types/nostr";
import {
getRepositoryIdentifier,
getRepositoryStateHead,
parseHeadBranch,
getRepositoryStateHeadCommit,
getRepositoryStateBranches,
getRepositoryStateTags,
} from "@/lib/nip34-helpers";
/**
* Detail renderer for Kind 30618 - Repository State
* Displays full repository state with all refs, branches, and tags
*/
export function RepositoryStateDetailRenderer({ event }: { event: NostrEvent }) {
const repoId = useMemo(() => getRepositoryIdentifier(event), [event]);
const headRef = useMemo(() => getRepositoryStateHead(event), [event]);
const branch = useMemo(() => parseHeadBranch(headRef), [event, headRef]);
const commitHash = useMemo(() => getRepositoryStateHeadCommit(event), [event]);
const branches = useMemo(() => getRepositoryStateBranches(event), [event]);
const tags = useMemo(() => getRepositoryStateTags(event), [event]);
const displayName = repoId || "Repository";
return (
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
{/* Repository Header */}
<header className="flex flex-col gap-4 border-b border-border pb-4">
{/* Name */}
<h1 className="text-3xl font-bold">{displayName}</h1>
{/* HEAD Info */}
{branch && commitHash && (
<div className="flex flex-col gap-2 p-3 bg-muted/30 rounded">
<div className="flex items-center gap-2">
<GitCommit className="size-4 text-muted-foreground" />
<span className="text-sm font-semibold">HEAD</span>
</div>
<div className="flex flex-col gap-1 pl-6">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Branch:</span>
<code className="text-sm font-mono font-semibold">{branch}</code>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Commit:</span>
<CommitHashItem hash={commitHash} />
</div>
</div>
</div>
)}
</header>
{/* Branches Section */}
{branches.length > 0 && (
<section className="flex flex-col gap-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<GitBranch className="size-5" />
Branches
</h2>
<ul className="flex flex-col gap-2">
{branches.map(({ name, hash }) => (
<li
key={name}
className="flex items-center justify-between gap-4 p-2 bg-muted/30 rounded"
>
<span className="text-sm font-mono font-semibold truncate">
{name}
</span>
<CommitHashItem hash={hash} />
</li>
))}
</ul>
</section>
)}
{/* Tags Section */}
{tags.length > 0 && (
<section className="flex flex-col gap-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Tag className="size-5" />
Tags
</h2>
<ul className="flex flex-col gap-2">
{tags.map(({ name, hash }) => (
<li
key={name}
className="flex items-center justify-between gap-4 p-2 bg-muted/30 rounded"
>
<span className="text-sm font-mono font-semibold truncate">
{name}
</span>
<CommitHashItem hash={hash} />
</li>
))}
</ul>
</section>
)}
{/* Raw HEAD Reference */}
{headRef && (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground">
HEAD Reference
</h2>
<code className="text-xs font-mono p-2 bg-muted/30 rounded">
{headRef}
</code>
</section>
)}
</div>
);
}
/**
* Component to display a commit hash with copy button
*/
function CommitHashItem({ hash }: { hash: string }) {
const { copy, copied } = useCopy();
const shortHash = hash.substring(0, 8);
return (
<div className="flex items-center gap-2 group">
<code className="text-xs font-mono text-muted-foreground">
{shortHash}
</code>
<button
onClick={() => copy(hash)}
className="flex-shrink-0 p-1 hover:bg-muted rounded"
aria-label="Copy commit hash"
title={hash}
>
{copied ? (
<CopyCheck className="size-3 text-muted-foreground" />
) : (
<Copy className="size-3 text-muted-foreground" />
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import {
BaseEventContainer,
type BaseEventProps,
ClickableEventTitle,
} from "./BaseEventRenderer";
import { GitCommit } from "lucide-react";
import {
getRepositoryIdentifier,
getRepositoryStateHeadCommit,
parseHeadBranch,
getRepositoryStateHead,
} from "@/lib/nip34-helpers";
/**
* Renderer for Kind 30618 - Repository State
* Displays as a compact git push notification in feed view
*/
export function RepositoryStateRenderer({ event }: BaseEventProps) {
const repoId = getRepositoryIdentifier(event);
const headRef = getRepositoryStateHead(event);
const branch = parseHeadBranch(headRef);
const commitHash = getRepositoryStateHeadCommit(event);
// Format: "pushed <8 chars of HEAD commit> to <branch> in <repo>"
const shortHash = commitHash?.substring(0, 8) || "unknown";
const branchName = branch || "unknown";
const repoName = repoId || "repository";
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Push notification */}
<div className="flex items-center gap-2">
<GitCommit className="size-4 text-muted-foreground flex-shrink-0" />
<ClickableEventTitle
event={event}
className="text-sm font-medium text-foreground"
as="span"
>
pushed{" "}
<code className="px-1.5 py-0.5 rounded bg-muted text-xs font-mono">
{shortHash}
</code>{" "}
to <span className="font-semibold">{branchName}</span> in{" "}
<span className="font-semibold">{repoName}</span>
</ClickableEventTitle>
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -30,6 +30,8 @@ import { CommunityNIPRenderer } from "./CommunityNIPRenderer";
import { CommunityNIPDetailRenderer } from "./CommunityNIPDetailRenderer";
import { RepositoryRenderer } from "./RepositoryRenderer";
import { RepositoryDetailRenderer } from "./RepositoryDetailRenderer";
import { RepositoryStateRenderer } from "./RepositoryStateRenderer";
import { RepositoryStateDetailRenderer } from "./RepositoryStateDetailRenderer";
import { Kind39701Renderer } from "./BookmarkRenderer";
import { GenericRelayListRenderer } from "./GenericRelayListRenderer";
import { LiveActivityRenderer } from "./LiveActivityRenderer";
@@ -73,6 +75,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
30023: Kind30023Renderer, // Long-form Article
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
30617: RepositoryRenderer, // Repository (NIP-34)
30618: RepositoryStateRenderer, // Repository State (NIP-34)
30817: CommunityNIPRenderer, // Community NIP
39701: Kind39701Renderer, // Web Bookmarks (NIP-B0)
};
@@ -128,6 +131,7 @@ const detailRenderers: Record<
30023: Kind30023DetailRenderer, // Long-form Article Detail
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
30817: CommunityNIPDetailRenderer, // Community NIP Detail
};

View File

@@ -281,3 +281,90 @@ export function getPullRequestRepositoryAddress(
): string | undefined {
return getTagValue(event, "a");
}
// ============================================================================
// Repository State Event Helpers (Kind 30618)
// ============================================================================
/**
* Get the HEAD reference from a repository state event
* @param event Repository state event (kind 30618)
* @returns HEAD reference (e.g., "ref: refs/heads/main") or undefined
*/
export function getRepositoryStateHead(event: NostrEvent): string | undefined {
return getTagValue(event, "HEAD");
}
/**
* Parse HEAD reference to extract branch name
* @param headRef HEAD reference string (e.g., "ref: refs/heads/main")
* @returns Branch name (e.g., "main") or undefined
*/
export function parseHeadBranch(headRef: string | undefined): string | undefined {
if (!headRef) return undefined;
const match = headRef.match(/^ref:\s*refs\/heads\/(.+)$/);
return match ? match[1] : undefined;
}
/**
* Get all git refs from a repository state event
* @param event Repository state event (kind 30618)
* @returns Array of { ref: string, hash: string } objects
*/
export function getRepositoryStateRefs(
event: NostrEvent,
): Array<{ ref: string; hash: string }> {
return event.tags
.filter((t) => t[0].startsWith("refs/"))
.map((t) => ({ ref: t[0], hash: t[1] }));
}
/**
* Get the commit hash that HEAD points to
* @param event Repository state event (kind 30618)
* @returns Commit hash or undefined
*/
export function getRepositoryStateHeadCommit(
event: NostrEvent,
): string | undefined {
const headRef = getRepositoryStateHead(event);
const branch = parseHeadBranch(headRef);
if (!branch) return undefined;
// Find the refs/heads/{branch} tag
const branchRef = `refs/heads/${branch}`;
const branchTag = event.tags.find((t) => t[0] === branchRef);
return branchTag ? branchTag[1] : undefined;
}
/**
* Get branches from repository state refs
* @param event Repository state event (kind 30618)
* @returns Array of { name: string, hash: string } objects
*/
export function getRepositoryStateBranches(
event: NostrEvent,
): Array<{ name: string; hash: string }> {
return event.tags
.filter((t) => t[0].startsWith("refs/heads/"))
.map((t) => ({
name: t[0].replace("refs/heads/", ""),
hash: t[1],
}));
}
/**
* Get tags from repository state refs
* @param event Repository state event (kind 30618)
* @returns Array of { name: string, hash: string } objects
*/
export function getRepositoryStateTags(
event: NostrEvent,
): Array<{ name: string; hash: string }> {
return event.tags
.filter((t) => t[0].startsWith("refs/tags/"))
.map((t) => ({
name: t[0].replace("refs/tags/", ""),
hash: t[1],
}));
}