From 1519e30e5c52f722b9fcfb545479a7d114a10cd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Dec 2025 12:35:23 +0000 Subject: [PATCH 1/3] 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 to in " Detail view shows: HEAD info, all branches, all tags with copyable commit hashes --- .../kinds/RepositoryStateDetailRenderer.tsx | 143 ++++++++++++++++++ .../nostr/kinds/RepositoryStateRenderer.tsx | 51 +++++++ src/components/nostr/kinds/index.tsx | 4 + src/lib/nip34-helpers.ts | 87 +++++++++++ 4 files changed, 285 insertions(+) create mode 100644 src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/RepositoryStateRenderer.tsx diff --git a/src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx b/src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx new file mode 100644 index 0000000..a3e17dc --- /dev/null +++ b/src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx @@ -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 ( +
+ {/* Repository Header */} +
+ {/* Name */} +

{displayName}

+ + {/* HEAD Info */} + {branch && commitHash && ( +
+
+ + HEAD +
+
+
+ Branch: + {branch} +
+
+ Commit: + +
+
+
+ )} +
+ + {/* Branches Section */} + {branches.length > 0 && ( +
+

+ + Branches +

+
    + {branches.map(({ name, hash }) => ( +
  • + + {name} + + +
  • + ))} +
+
+ )} + + {/* Tags Section */} + {tags.length > 0 && ( +
+

+ + Tags +

+
    + {tags.map(({ name, hash }) => ( +
  • + + {name} + + +
  • + ))} +
+
+ )} + + {/* Raw HEAD Reference */} + {headRef && ( +
+

+ HEAD Reference +

+ + {headRef} + +
+ )} +
+ ); +} + +/** + * 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 ( +
+ + {shortHash} + + +
+ ); +} diff --git a/src/components/nostr/kinds/RepositoryStateRenderer.tsx b/src/components/nostr/kinds/RepositoryStateRenderer.tsx new file mode 100644 index 0000000..71cf5f1 --- /dev/null +++ b/src/components/nostr/kinds/RepositoryStateRenderer.tsx @@ -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 in " + const shortHash = commitHash?.substring(0, 8) || "unknown"; + const branchName = branch || "unknown"; + const repoName = repoId || "repository"; + + return ( + +
+ {/* Push notification */} +
+ + + pushed{" "} + + {shortHash} + {" "} + to {branchName} in{" "} + {repoName} + +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index a10da0b..fce03f9 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -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> = { 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 }; diff --git a/src/lib/nip34-helpers.ts b/src/lib/nip34-helpers.ts index 97aa0e7..c921686 100644 --- a/src/lib/nip34-helpers.ts +++ b/src/lib/nip34-helpers.ts @@ -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], + })); +} From 2792cab04dc0da8a08ed426dd2868c2fb01fd22f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Dec 2025 12:50:08 +0000 Subject: [PATCH 2/3] feat: link repository names in repository state renderers - Repository name in both feed and detail views now links to the repository (kind 30617) - Uses same link styling as issues: cursor-crosshair, underline decoration-dotted - Fetches repository event to display proper name instead of identifier - Only "pushed " part opens the state event itself - Repository link opens in new window using addWindow("open") --- .../kinds/RepositoryStateDetailRenderer.tsx | 47 ++++++++++++-- .../nostr/kinds/RepositoryStateRenderer.tsx | 63 ++++++++++++++----- 2 files changed, 91 insertions(+), 19 deletions(-) diff --git a/src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx b/src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx index a3e17dc..6393f2d 100644 --- a/src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx +++ b/src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx @@ -1,6 +1,8 @@ import { useMemo } from "react"; -import { GitBranch, GitCommit, Tag, Copy, CopyCheck } from "lucide-react"; +import { GitBranch, GitCommit, Tag, Copy, CopyCheck, FolderGit2 } from "lucide-react"; import { useCopy } from "@/hooks/useCopy"; +import { useGrimoire } from "@/core/state"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; import type { NostrEvent } from "@/types/nostr"; import { getRepositoryIdentifier, @@ -9,6 +11,7 @@ import { getRepositoryStateHeadCommit, getRepositoryStateBranches, getRepositoryStateTags, + getRepositoryName, } from "@/lib/nip34-helpers"; /** @@ -16,6 +19,7 @@ import { * Displays full repository state with all refs, branches, and tags */ export function RepositoryStateDetailRenderer({ event }: { event: NostrEvent }) { + const { addWindow } = useGrimoire(); const repoId = useMemo(() => getRepositoryIdentifier(event), [event]); const headRef = useMemo(() => getRepositoryStateHead(event), [event]); const branch = useMemo(() => parseHeadBranch(headRef), [event, headRef]); @@ -23,14 +27,49 @@ export function RepositoryStateDetailRenderer({ event }: { event: NostrEvent }) const branches = useMemo(() => getRepositoryStateBranches(event), [event]); const tags = useMemo(() => getRepositoryStateTags(event), [event]); - const displayName = repoId || "Repository"; + // Create repository pointer (kind 30617) + const repoPointer = useMemo( + () => + repoId + ? { + kind: 30617, + pubkey: event.pubkey, + identifier: repoId, + } + : null, + [repoId, event.pubkey], + ); + + // Fetch the repository event to get its name + const repoEvent = useNostrEvent(repoPointer || undefined); + + // Get repository display name + const displayName = repoEvent + ? getRepositoryName(repoEvent) || repoId || "Repository" + : repoId || "Repository"; + + const handleRepoClick = () => { + if (repoPointer) { + addWindow("open", { pointer: repoPointer }); + } + }; return (
{/* Repository Header */}
- {/* Name */} -

{displayName}

+ {/* Name with link to repository */} + {repoPointer ? ( +

+ + {displayName} +

+ ) : ( +

{displayName}

+ )} {/* HEAD Info */} {branch && commitHash && ( diff --git a/src/components/nostr/kinds/RepositoryStateRenderer.tsx b/src/components/nostr/kinds/RepositoryStateRenderer.tsx index 71cf5f1..1cc21d6 100644 --- a/src/components/nostr/kinds/RepositoryStateRenderer.tsx +++ b/src/components/nostr/kinds/RepositoryStateRenderer.tsx @@ -3,12 +3,15 @@ import { type BaseEventProps, ClickableEventTitle, } from "./BaseEventRenderer"; -import { GitCommit } from "lucide-react"; +import { GitCommit, FolderGit2 } from "lucide-react"; +import { useGrimoire } from "@/core/state"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; import { getRepositoryIdentifier, getRepositoryStateHeadCommit, parseHeadBranch, getRepositoryStateHead, + getRepositoryName, } from "@/lib/nip34-helpers"; /** @@ -16,34 +19,64 @@ import { * Displays as a compact git push notification in feed view */ export function RepositoryStateRenderer({ event }: BaseEventProps) { + const { addWindow } = useGrimoire(); const repoId = getRepositoryIdentifier(event); const headRef = getRepositoryStateHead(event); const branch = parseHeadBranch(headRef); const commitHash = getRepositoryStateHeadCommit(event); - // Format: "pushed <8 chars of HEAD commit> to in " + // Create repository pointer (kind 30617) + const repoPointer = repoId + ? { + kind: 30617, + pubkey: event.pubkey, + identifier: repoId, + } + : null; + + // Fetch the repository event to get its name + const repoEvent = useNostrEvent(repoPointer || undefined); + + // Get repository display name + const repoName = repoEvent + ? getRepositoryName(repoEvent) || repoId || "Repository" + : repoId || "repository"; + const shortHash = commitHash?.substring(0, 8) || "unknown"; const branchName = branch || "unknown"; - const repoName = repoId || "repository"; + + const handleRepoClick = () => { + if (repoPointer) { + addWindow("open", { pointer: repoPointer }); + } + }; return (
{/* Push notification */} -
+
- - pushed{" "} - - {shortHash} - {" "} +
+ + pushed{" "} + + {shortHash} + + {" "} to {branchName} in{" "} - {repoName} - + {repoPointer ? ( + + + {repoName} + + ) : ( + {repoName} + )} +
From a34fa2becdca37ddda38c2da47ac139a7ac8f245 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Dec 2025 14:28:12 +0000 Subject: [PATCH 3/3] fix: apply linter formatting and fix useMemo dependencies --- .../kinds/RepositoryStateDetailRenderer.tsx | 26 +++++++++++++++---- src/lib/nip34-helpers.ts | 4 ++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx b/src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx index 6393f2d..8e70ffb 100644 --- a/src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx +++ b/src/components/nostr/kinds/RepositoryStateDetailRenderer.tsx @@ -1,5 +1,12 @@ import { useMemo } from "react"; -import { GitBranch, GitCommit, Tag, Copy, CopyCheck, FolderGit2 } from "lucide-react"; +import { + GitBranch, + GitCommit, + Tag, + Copy, + CopyCheck, + FolderGit2, +} from "lucide-react"; import { useCopy } from "@/hooks/useCopy"; import { useGrimoire } from "@/core/state"; import { useNostrEvent } from "@/hooks/useNostrEvent"; @@ -18,12 +25,19 @@ import { * Detail renderer for Kind 30618 - Repository State * Displays full repository state with all refs, branches, and tags */ -export function RepositoryStateDetailRenderer({ event }: { event: NostrEvent }) { +export function RepositoryStateDetailRenderer({ + event, +}: { + event: NostrEvent; +}) { const { addWindow } = useGrimoire(); 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 branch = useMemo(() => parseHeadBranch(headRef), [headRef]); + const commitHash = useMemo( + () => getRepositoryStateHeadCommit(event), + [event], + ); const branches = useMemo(() => getRepositoryStateBranches(event), [event]); const tags = useMemo(() => getRepositoryStateTags(event), [event]); @@ -81,7 +95,9 @@ export function RepositoryStateDetailRenderer({ event }: { event: NostrEvent })
Branch: - {branch} + + {branch} +
Commit: diff --git a/src/lib/nip34-helpers.ts b/src/lib/nip34-helpers.ts index c921686..cac5ec7 100644 --- a/src/lib/nip34-helpers.ts +++ b/src/lib/nip34-helpers.ts @@ -300,7 +300,9 @@ export function getRepositoryStateHead(event: NostrEvent): string | undefined { * @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 { +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;