mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
fix: PR body rendering
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { ReactElement, useMemo } from "react";
|
||||
import { WindowInstance } from "@/types/app";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/constants/command-icons";
|
||||
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { kinds, nip19 } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { ProfileContent } from "applesauce-core/helpers";
|
||||
import {
|
||||
formatEventIds,
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
formatTimeRangeCompact,
|
||||
formatGenericTag,
|
||||
} from "@/lib/filter-formatters";
|
||||
import { getEventDisplayTitle } from "@/lib/event-title";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
|
||||
export interface WindowTitleData {
|
||||
title: string;
|
||||
title: string | ReactElement;
|
||||
icon?: LucideIcon;
|
||||
tooltip?: string;
|
||||
}
|
||||
@@ -205,36 +207,24 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
return `Profile ${profilePubkey.slice(0, 8)}...`;
|
||||
}, [appId, profilePubkey, profile]);
|
||||
|
||||
// Event titles
|
||||
// Event titles - use unified title extraction
|
||||
const eventPointer: EventPointer | AddressPointer | undefined =
|
||||
appId === "open" ? props.pointer : undefined;
|
||||
const event = useNostrEvent(eventPointer);
|
||||
const eventTitle = useMemo(() => {
|
||||
if (appId !== "open" || !event) return null;
|
||||
|
||||
const kindName = getKindName(event.kind);
|
||||
|
||||
// For text-based events, show a preview
|
||||
if (event.kind === kinds.ShortTextNote && event.content) {
|
||||
const preview = event.content.slice(0, 40).trim();
|
||||
return preview ? `${kindName}: ${preview}...` : kindName;
|
||||
}
|
||||
|
||||
// For articles (kind 30023), show title tag
|
||||
if (event.kind === kinds.LongFormArticle) {
|
||||
const titleTag = event.tags.find((t) => t[0] === "title")?.[1];
|
||||
if (titleTag) {
|
||||
return titleTag.length > 50 ? `${titleTag.slice(0, 50)}...` : titleTag;
|
||||
}
|
||||
}
|
||||
|
||||
// For highlights (kind 9802), show preview
|
||||
if (event.kind === kinds.Highlights && event.content) {
|
||||
const preview = event.content.slice(0, 40).trim();
|
||||
return preview ? `Highlight: ${preview}...` : "Highlight";
|
||||
}
|
||||
|
||||
return kindName;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0">
|
||||
{getKindName(event.kind)}
|
||||
<span>:</span>
|
||||
</div>
|
||||
{getEventDisplayTitle(event, false)}
|
||||
<span> - </span>
|
||||
<UserName pubkey={event.pubkey} />
|
||||
</div>
|
||||
);
|
||||
}, [appId, event]);
|
||||
|
||||
// Kind titles
|
||||
@@ -429,7 +419,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
|
||||
// Generate final title data with icon and tooltip
|
||||
return useMemo(() => {
|
||||
let title: string;
|
||||
let title: ReactElement | string;
|
||||
let icon: LucideIcon | undefined;
|
||||
let tooltip: string | undefined;
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { KindRenderer } from "./nostr/kinds";
|
||||
import { Kind0DetailRenderer } from "./nostr/kinds/Kind0DetailRenderer";
|
||||
import { Kind3DetailView } from "./nostr/kinds/Kind3Renderer";
|
||||
import { Kind1621DetailRenderer } from "./nostr/kinds/Kind1621DetailRenderer";
|
||||
import { IssueDetailRenderer } from "./nostr/kinds/IssueDetailRenderer";
|
||||
import { PatchDetailRenderer } from "./nostr/kinds/PatchDetailRenderer";
|
||||
import { PullRequestDetailRenderer } from "./nostr/kinds/PullRequestDetailRenderer";
|
||||
import { Kind9802DetailRenderer } from "./nostr/kinds/Kind9802DetailRenderer";
|
||||
import { Kind10002DetailRenderer } from "./nostr/kinds/Kind10002DetailRenderer";
|
||||
import { Kind30023DetailRenderer } from "./nostr/kinds/Kind30023DetailRenderer";
|
||||
import { Kind30617DetailRenderer } from "./nostr/kinds/Kind30617DetailRenderer";
|
||||
import { RepositoryDetailRenderer } from "./nostr/kinds/RepositoryDetailRenderer";
|
||||
import { JsonViewer } from "./JsonViewer";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
import {
|
||||
@@ -267,8 +269,12 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
|
||||
<Kind0DetailRenderer event={event} />
|
||||
) : event.kind === kinds.Contacts ? (
|
||||
<Kind3DetailView event={event} />
|
||||
) : event.kind === 1617 ? (
|
||||
<PatchDetailRenderer event={event} />
|
||||
) : event.kind === 1618 ? (
|
||||
<PullRequestDetailRenderer event={event} />
|
||||
) : event.kind === 1621 ? (
|
||||
<Kind1621DetailRenderer event={event} />
|
||||
<IssueDetailRenderer event={event} />
|
||||
) : event.kind === kinds.Highlights ? (
|
||||
<Kind9802DetailRenderer event={event} />
|
||||
) : event.kind === kinds.RelayList ? (
|
||||
@@ -276,7 +282,7 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
|
||||
) : event.kind === kinds.LongFormArticle ? (
|
||||
<Kind30023DetailRenderer event={event} />
|
||||
) : event.kind === 30617 ? (
|
||||
<Kind30617DetailRenderer event={event} />
|
||||
<RepositoryDetailRenderer event={event} />
|
||||
) : (
|
||||
<KindRenderer event={event} />
|
||||
)}
|
||||
|
||||
@@ -284,7 +284,7 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
|
||||
/>
|
||||
{/* NIP-05 */}
|
||||
{profile.nip05 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-xs">
|
||||
<Nip05 pubkey={pubkey} profile={profile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,10 @@ export function WindowTile({
|
||||
const { title, icon, tooltip } = useDynamicWindowTitle(window);
|
||||
const Icon = icon;
|
||||
|
||||
// Convert title to string for MosaicWindow (which only accepts strings)
|
||||
// The actual title (with React elements) is rendered in the custom toolbar
|
||||
const titleString = typeof title === "string" ? title : tooltip || window.title;
|
||||
|
||||
// Custom toolbar renderer to include icon
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
@@ -47,7 +51,7 @@ export function WindowTile({
|
||||
};
|
||||
|
||||
return (
|
||||
<MosaicWindow path={path} title={title} renderToolbar={renderToolbar}>
|
||||
<MosaicWindow path={path} title={titleString} renderToolbar={renderToolbar}>
|
||||
<ErrorBoundary level="window">
|
||||
<WindowRenderer window={window} onClose={() => onClose(id)} />
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PlainLinkProps {
|
||||
url: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PlainLink({ url }: PlainLinkProps) {
|
||||
export function PlainLink({ url, className }: PlainLinkProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Format URL for display: remove scheme and trailing slashes
|
||||
const displayUrl = url
|
||||
.replace(/^https?:\/\//, "") // Remove http:// or https://
|
||||
.replace(/\/$/, ""); // Remove trailing slash
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground underline decoration-dotted hover:text-foreground cursor-crosshair break-all"
|
||||
className={cn(
|
||||
"text-muted-foreground underline decoration-dotted cursor-crosshair hover:text-foreground break-all",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{url}
|
||||
{displayUrl}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,36 +94,24 @@ export function RichText({
|
||||
}
|
||||
: undefined;
|
||||
const renderedContent = useRenderedContent(
|
||||
trimmedEvent as NostrEvent,
|
||||
content
|
||||
? ({
|
||||
content,
|
||||
} as NostrEvent)
|
||||
: trimmedEvent,
|
||||
contentComponents,
|
||||
);
|
||||
|
||||
// If plain content is provided, just render it
|
||||
if (content && !event) {
|
||||
const lines = content.trim().split("\n");
|
||||
return (
|
||||
<div className={cn("leading-relaxed break-words", className)}>
|
||||
{lines.map((line, idx) => (
|
||||
<div key={idx} dir="auto">
|
||||
{line || "\u00A0"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render event content with rich formatting
|
||||
if (event) {
|
||||
return (
|
||||
<DepthContext.Provider value={depth}>
|
||||
<OptionsContext.Provider value={mergedOptions}>
|
||||
<div className={cn("leading-relaxed break-words", className)}>
|
||||
{renderedContent}
|
||||
</div>
|
||||
</OptionsContext.Provider>
|
||||
</DepthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<DepthContext.Provider value={depth}>
|
||||
<OptionsContext.Provider value={mergedOptions}>
|
||||
<div
|
||||
dir="auto"
|
||||
className={cn("leading-relaxed break-words", className)}
|
||||
>
|
||||
{renderedContent}
|
||||
</div>
|
||||
</OptionsContext.Provider>
|
||||
</DepthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function Link({ node }: LinkNodeProps) {
|
||||
type="image"
|
||||
preset="inline"
|
||||
enableZoom
|
||||
className="inline-block"
|
||||
className="my-2 inline-block"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function Link({ node }: LinkNodeProps) {
|
||||
url={href}
|
||||
type="video"
|
||||
preset="inline"
|
||||
className="inline-block"
|
||||
className="my-2 inline-block"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export function Link({ node }: LinkNodeProps) {
|
||||
url={href}
|
||||
type="audio"
|
||||
onAudioClick={handleAudioClick}
|
||||
className="inline-block"
|
||||
className="my-2 inline-block"
|
||||
/>
|
||||
<MediaDialog
|
||||
open={dialogOpen}
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
import { CommonData } from "applesauce-content/nast";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface TextNodeProps {
|
||||
node: {
|
||||
type: "text";
|
||||
value: string;
|
||||
data?: CommonData;
|
||||
};
|
||||
}
|
||||
|
||||
// Check if text contains RTL characters (Arabic, Hebrew, Persian, etc.)
|
||||
function hasRTLCharacters(text: string): boolean {
|
||||
return /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/.test(text);
|
||||
}
|
||||
|
||||
export function Text({ node }: TextNodeProps) {
|
||||
const text = node.value;
|
||||
|
||||
// If no newlines, render as inline span
|
||||
if (!text.includes("\n")) {
|
||||
const isRTL = hasRTLCharacters(text);
|
||||
return <span dir={isRTL ? "rtl" : "auto"}>{text || "\u00A0"}</span>;
|
||||
const lines = useMemo(() => text.split("\n"), [text]);
|
||||
if (text.includes("\n")) {
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, idx) =>
|
||||
line.trim().length === 0 ? (
|
||||
<br />
|
||||
) : idx === 0 || idx === lines.length - 1 ? (
|
||||
<span dir="auto">{line}</span> // FIXME: this should be span or div depnding on context
|
||||
) : (
|
||||
<div dir="auto" key={idx}>
|
||||
{line}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// If has newlines, use regular inline spans with <br> tags
|
||||
const lines = text.split("\n");
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, idx) => {
|
||||
const isRTL = hasRTLCharacters(line);
|
||||
return (
|
||||
<>
|
||||
{idx > 0 && <br key={`br-${idx}`} />}
|
||||
<span key={idx} dir={isRTL ? "rtl" : "auto"}>
|
||||
{line || "\u00A0"}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return <span dir="auto">{text}</span>;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { EventFooter } from "@/components/EventFooter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getEventDisplayTitle } from "@/lib/event-title";
|
||||
|
||||
// NIP-01 Kind ranges
|
||||
const REPLACEABLE_START = 10000;
|
||||
@@ -75,7 +77,9 @@ export function EventMenu({ event }: { event: NostrEvent }) {
|
||||
};
|
||||
}
|
||||
|
||||
addWindow("open", { pointer }, `Event ${event.id.slice(0, 8)}...`);
|
||||
// Use automatic title extraction for better window titles
|
||||
const title = getEventDisplayTitle(event);
|
||||
addWindow("open", { pointer }, title);
|
||||
};
|
||||
|
||||
const copyEventId = () => {
|
||||
@@ -156,6 +160,72 @@ export function EventMenu({ event }: { event: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clickable event title component
|
||||
* Opens the event in a new window when clicked
|
||||
* Supports both regular events and addressable/replaceable events
|
||||
*/
|
||||
interface ClickableEventTitleProps {
|
||||
event: NostrEvent;
|
||||
children: React.ReactNode;
|
||||
windowTitle?: string;
|
||||
className?: string;
|
||||
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "span" | "div";
|
||||
}
|
||||
|
||||
export function ClickableEventTitle({
|
||||
event,
|
||||
children,
|
||||
windowTitle,
|
||||
className,
|
||||
as: Component = "h3",
|
||||
}: ClickableEventTitleProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Determine if event is addressable/replaceable
|
||||
const isAddressable =
|
||||
(event.kind >= REPLACEABLE_START && event.kind < REPLACEABLE_END) ||
|
||||
(event.kind >= PARAMETERIZED_REPLACEABLE_START &&
|
||||
event.kind < PARAMETERIZED_REPLACEABLE_END);
|
||||
|
||||
let pointer;
|
||||
// Use provided windowTitle, or fall back to automatic title extraction
|
||||
const title = windowTitle || getEventDisplayTitle(event);
|
||||
|
||||
if (isAddressable) {
|
||||
// For replaceable/parameterized replaceable events, use AddressPointer
|
||||
const dTag = getTagValue(event, "d") || "";
|
||||
pointer = {
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
identifier: dTag,
|
||||
};
|
||||
} else {
|
||||
// For regular events, use EventPointer
|
||||
pointer = {
|
||||
id: event.id,
|
||||
};
|
||||
}
|
||||
|
||||
addWindow("open", { pointer }, title);
|
||||
};
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
"cursor-crosshair hover:underline hover:decoration-dotted",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base event container with universal header
|
||||
* Kind-specific renderers can wrap their content with this
|
||||
|
||||
@@ -3,7 +3,7 @@ import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { remarkNostrMentions } from "applesauce-content/markdown";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { Tag, GitBranch } from "lucide-react";
|
||||
import { Tag, FolderGit2 } from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { EmbeddedEvent } from "../EmbeddedEvent";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
@@ -127,7 +127,7 @@ function NostrMention({ href }: { href: string }) {
|
||||
* Detail renderer for Kind 1621 - Issue
|
||||
* Displays full issue content with markdown rendering
|
||||
*/
|
||||
export function Kind1621DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
export function IssueDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const title = useMemo(() => getIssueTitle(event), [event]);
|
||||
const labels = useMemo(() => getIssueLabels(event), [event]);
|
||||
@@ -193,7 +193,7 @@ export function Kind1621DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
: "text-muted-foreground cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="size-4" />
|
||||
<FolderGit2 className="size-4" />
|
||||
{repoName}
|
||||
</button>
|
||||
</div>
|
||||
@@ -309,7 +309,7 @@ export function Kind1621DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
hr: () => <hr className="my-4" />,
|
||||
}}
|
||||
>
|
||||
{event.content}
|
||||
{event.content.replace(/\\n/g, '\n')}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
) : (
|
||||
@@ -1,5 +1,9 @@
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import {
|
||||
@@ -17,7 +21,7 @@ import { UserName } from "../UserName";
|
||||
* Renderer for Kind 1621 - Issue
|
||||
* Displays as a compact issue card in feed view
|
||||
*/
|
||||
export function Kind1621Renderer({ event }: BaseEventProps) {
|
||||
export function IssueRenderer({ event }: BaseEventProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const title = getIssueTitle(event);
|
||||
const labels = getIssueLabels(event);
|
||||
@@ -66,9 +70,13 @@ export function Kind1621Renderer({ event }: BaseEventProps) {
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Issue Title */}
|
||||
<h3 className="font-semibold text-foreground">
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={title || "Untitled Issue"}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{title || "Untitled Issue"}
|
||||
</h3>
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Repository Reference */}
|
||||
{repoAddress && repoPointer && (
|
||||
@@ -80,7 +88,7 @@ export function Kind1621Renderer({ event }: BaseEventProps) {
|
||||
cursor-crosshair underline decoration-dotted hover:text-primary
|
||||
`}
|
||||
>
|
||||
<GitBranch className="size-3" />
|
||||
<FolderGit2 className="size-3" />
|
||||
<span>{repoName}</span>
|
||||
</div>
|
||||
<span>by</span>
|
||||
@@ -163,7 +163,7 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
|
||||
<div dir="auto" className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
|
||||
{/* Article Header */}
|
||||
<header className="flex flex-col gap-4 border-b border-border pb-6">
|
||||
{/* Title */}
|
||||
@@ -290,7 +290,7 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
hr: () => <hr className="my-4" />,
|
||||
}}
|
||||
>
|
||||
{event.content}
|
||||
{event.content.replace(/\\n/g, '\n')}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { BaseEventContainer, BaseEventProps } from "./BaseEventRenderer";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import {
|
||||
getArticleTitle,
|
||||
getArticleSummary,
|
||||
@@ -15,10 +19,16 @@ export function Kind30023Renderer({ event }: BaseEventProps) {
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div dir="auto" className="flex flex-col gap-2">
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<h3 className="text-lg font-bold text-foreground">{title}</h3>
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={title}
|
||||
className="text-lg font-bold text-foreground"
|
||||
>
|
||||
{title}
|
||||
</ClickableEventTitle>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BaseEventContainer, BaseEventProps } from "./BaseEventRenderer";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { RichText } from "../RichText";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
@@ -22,7 +26,13 @@ export function Kind39701Renderer({ event }: BaseEventProps) {
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<h3 className="text-lg font-bold text-foreground">{title}</h3>
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={title}
|
||||
className="text-lg font-bold text-foreground"
|
||||
>
|
||||
{title}
|
||||
</ClickableEventTitle>
|
||||
)}
|
||||
|
||||
{/* URL with external link icon */}
|
||||
|
||||
225
src/components/nostr/kinds/PatchDetailRenderer.tsx
Normal file
225
src/components/nostr/kinds/PatchDetailRenderer.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useMemo } from "react";
|
||||
import { GitCommit, FolderGit2, User, Copy, CopyCheck } from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import {
|
||||
getPatchSubject,
|
||||
getPatchCommitId,
|
||||
getPatchParentCommit,
|
||||
getPatchCommitter,
|
||||
getPatchRepositoryAddress,
|
||||
isPatchRoot,
|
||||
isPatchRootRevision,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 1617 - Patch
|
||||
* Displays full patch metadata and content
|
||||
*/
|
||||
export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const { copy, copied } = useCopy();
|
||||
|
||||
const subject = useMemo(() => getPatchSubject(event), [event]);
|
||||
const commitId = useMemo(() => getPatchCommitId(event), [event]);
|
||||
const parentCommit = useMemo(() => getPatchParentCommit(event), [event]);
|
||||
const committer = useMemo(() => getPatchCommitter(event), [event]);
|
||||
const repoAddress = useMemo(() => getPatchRepositoryAddress(event), [event]);
|
||||
const isRoot = useMemo(() => isPatchRoot(event), [event]);
|
||||
const isRootRevision = useMemo(() => isPatchRootRevision(event), [event]);
|
||||
|
||||
// Parse repository address
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!repoAddress) return null;
|
||||
try {
|
||||
const [kindStr, pubkey, identifier] = repoAddress.split(":");
|
||||
return {
|
||||
kind: parseInt(kindStr),
|
||||
pubkey,
|
||||
identifier,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [repoAddress]);
|
||||
|
||||
// Fetch repository event
|
||||
const repoEvent = useNostrEvent(repoPointer || undefined);
|
||||
|
||||
// Get repository display name
|
||||
const repoName = repoEvent
|
||||
? getRepositoryName(repoEvent) ||
|
||||
getRepositoryIdentifier(repoEvent) ||
|
||||
"Repository"
|
||||
: repoPointer?.identifier || "Unknown Repository";
|
||||
|
||||
const handleRepoClick = () => {
|
||||
if (!repoPointer || !repoEvent) return;
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
};
|
||||
|
||||
// Format created date
|
||||
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
|
||||
{/* Patch Header */}
|
||||
<header className="flex flex-col gap-4 pb-4 border-b border-border">
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold">{subject || "Untitled Patch"}</h1>
|
||||
|
||||
{/* Status Badges */}
|
||||
{(isRoot || isRootRevision) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isRoot && (
|
||||
<span className="px-3 py-1 bg-accent/20 text-accent text-sm border border-accent/30">
|
||||
Root Patch
|
||||
</span>
|
||||
)}
|
||||
{isRootRevision && (
|
||||
<span className="px-3 py-1 bg-primary/20 text-primary text-sm border border-primary/30">
|
||||
Root Revision
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repository Link */}
|
||||
{repoAddress && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Repository:</span>
|
||||
<button
|
||||
onClick={repoEvent ? handleRepoClick : undefined}
|
||||
disabled={!repoEvent}
|
||||
className={`flex items-center gap-2 font-mono ${
|
||||
repoEvent
|
||||
? "text-muted-foreground underline decoration-dotted cursor-crosshair hover:text-primary"
|
||||
: "text-muted-foreground cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<FolderGit2 className="size-4" />
|
||||
{repoName}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>By</span>
|
||||
<UserName pubkey={event.pubkey} className="font-semibold" />
|
||||
</div>
|
||||
<span>•</span>
|
||||
<time>{createdDate}</time>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Commit Information */}
|
||||
{(commitId || parentCommit || committer) && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<GitCommit className="size-5 flex-shrink-0" />
|
||||
Commit Information
|
||||
</h2>
|
||||
|
||||
{/* Commit ID */}
|
||||
{commitId && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/30">
|
||||
<span className="text-sm text-muted-foreground">Commit:</span>
|
||||
<code className="flex-1 text-sm font-mono line-clamp-1 truncate">
|
||||
{commitId}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(commitId)}
|
||||
className="flex-shrink-0 p-1 hover:bg-muted"
|
||||
aria-label="Copy commit ID"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parent Commit */}
|
||||
{parentCommit && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/30">
|
||||
<span className="text-sm text-muted-foreground">Parent:</span>
|
||||
<code className="flex-1 text-sm font-mono truncate line-clamp-1">
|
||||
{parentCommit}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(parentCommit)}
|
||||
className="flex-shrink-0 p-1 hover:bg-muted"
|
||||
aria-label="Copy parent commit ID"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Committer Info */}
|
||||
{committer && (
|
||||
<div className="flex items-start gap-2 p-2 bg-muted/30">
|
||||
<User className="size-4 text-muted-foreground mt-0.5" />
|
||||
<div className="flex flex-row gap-2 text-sm truncate line-clamp-1">
|
||||
<span className="text-muted-foreground">Committer: </span>
|
||||
<div className="flex flex-row gap-1 truncate line-clamp-1">
|
||||
<span className="font-semibold">{committer.name}</span>
|
||||
{committer.email && (
|
||||
<span className="text-muted-foreground">
|
||||
<{committer.email}>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Patch Content */}
|
||||
{event.content && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold">Patch</h2>
|
||||
<div className="relative">
|
||||
<pre className="overflow-x-auto text-xs font-mono bg-muted/30 p-4 whitespace-pre-wrap break-words">
|
||||
{event.content}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => copy(event.content)}
|
||||
className="absolute top-2 right-2 p-2 bg-background/90 hover:bg-muted border border-border rounded"
|
||||
aria-label="Copy patch"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/nostr/kinds/PatchRenderer.tsx
Normal file
111
src/components/nostr/kinds/PatchRenderer.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import {
|
||||
getPatchSubject,
|
||||
getPatchCommitId,
|
||||
getPatchRepositoryAddress,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1617 - Patch
|
||||
* Displays as a compact patch card in feed view
|
||||
*/
|
||||
export function PatchRenderer({ event }: BaseEventProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const subject = getPatchSubject(event);
|
||||
const commitId = getPatchCommitId(event);
|
||||
const repoAddress = getPatchRepositoryAddress(event);
|
||||
|
||||
// Parse repository address to get the pointer
|
||||
const repoPointer = repoAddress
|
||||
? (() => {
|
||||
try {
|
||||
// Address format: "kind:pubkey:identifier"
|
||||
const [kindStr, pubkey, identifier] = repoAddress.split(":");
|
||||
return {
|
||||
kind: parseInt(kindStr),
|
||||
pubkey,
|
||||
identifier,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
|
||||
// Fetch the repository event to get its name
|
||||
const repoEvent = useNostrEvent(
|
||||
repoPointer
|
||||
? {
|
||||
kind: repoPointer.kind,
|
||||
pubkey: repoPointer.pubkey,
|
||||
identifier: repoPointer.identifier,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Get repository display name
|
||||
const repoName = repoEvent
|
||||
? getRepositoryName(repoEvent) ||
|
||||
getRepositoryIdentifier(repoEvent) ||
|
||||
"Repository"
|
||||
: repoAddress?.split(":")[2] || "Unknown Repository";
|
||||
|
||||
const handleRepoClick = () => {
|
||||
if (!repoPointer) return;
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
};
|
||||
|
||||
// Shorten commit ID for display
|
||||
const shortCommitId = commitId ? commitId.slice(0, 7) : undefined;
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Patch Subject */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={subject || "Untitled Patch"}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{subject || "Untitled Patch"}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>in</span>
|
||||
{/* Repository */}
|
||||
{repoAddress && repoPointer && (
|
||||
<div
|
||||
onClick={handleRepoClick}
|
||||
className="flex items-center gap-1 text-muted-foreground cursor-crosshair underline decoration-dotted hover:text-primary"
|
||||
>
|
||||
<FolderGit2 className="size-3" />
|
||||
<span>{repoName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commit ID */}
|
||||
{shortCommitId && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<code className="text-muted-foreground font-mono text-xs">
|
||||
{shortCommitId}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
437
src/components/nostr/kinds/PullRequestDetailRenderer.tsx
Normal file
437
src/components/nostr/kinds/PullRequestDetailRenderer.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { useMemo } from "react";
|
||||
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { remarkNostrMentions } from "applesauce-content/markdown";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { GitBranch, FolderGit2, Tag, Copy, CopyCheck } from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { EmbeddedEvent } from "../EmbeddedEvent";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import {
|
||||
getPullRequestSubject,
|
||||
getPullRequestLabels,
|
||||
getPullRequestCommitId,
|
||||
getPullRequestBranchName,
|
||||
getPullRequestCloneUrls,
|
||||
getPullRequestMergeBase,
|
||||
getPullRequestRepositoryAddress,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
|
||||
/**
|
||||
* Component to render nostr: mentions inline
|
||||
*/
|
||||
function NostrMention({ href }: { href: string }) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
try {
|
||||
const cleanHref = href.replace(/^nostr:/, "").trim();
|
||||
|
||||
if (!cleanHref.match(/^(npub|nprofile|note|nevent|naddr)/)) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted break-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = nip19.decode(cleanHref);
|
||||
|
||||
switch (parsed.type) {
|
||||
case "npub":
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<UserName
|
||||
pubkey={parsed.data}
|
||||
className="text-accent font-semibold"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
case "nprofile":
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<UserName
|
||||
pubkey={parsed.data.pubkey}
|
||||
className="text-accent font-semibold"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
case "note":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
eventId={parsed.data}
|
||||
onOpen={(id) => {
|
||||
addWindow(
|
||||
"open",
|
||||
{ id: id as string },
|
||||
`Event ${(id as string).slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "nevent":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
eventId={parsed.data.id}
|
||||
onOpen={(id) => {
|
||||
addWindow(
|
||||
"open",
|
||||
{ id: id as string },
|
||||
`Event ${(id as string).slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "naddr":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
addressPointer={parsed.data}
|
||||
onOpen={(pointer) => {
|
||||
addWindow(
|
||||
"open",
|
||||
pointer,
|
||||
`${parsed.data.kind}:${parsed.data.identifier.slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <span className="text-muted-foreground">{cleanHref}</span>;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse nostr link:", href, error);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted break-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 1618 - Pull Request
|
||||
* Displays full PR content with markdown rendering
|
||||
*/
|
||||
export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const { copy, copied } = useCopy();
|
||||
|
||||
const subject = useMemo(() => getPullRequestSubject(event), [event]);
|
||||
const labels = useMemo(() => getPullRequestLabels(event), [event]);
|
||||
const commitId = useMemo(() => getPullRequestCommitId(event), [event]);
|
||||
const branchName = useMemo(() => getPullRequestBranchName(event), [event]);
|
||||
const cloneUrls = useMemo(() => getPullRequestCloneUrls(event), [event]);
|
||||
const mergeBase = useMemo(() => getPullRequestMergeBase(event), [event]);
|
||||
const repoAddress = useMemo(
|
||||
() => getPullRequestRepositoryAddress(event),
|
||||
[event],
|
||||
);
|
||||
|
||||
// Parse repository address
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!repoAddress) return null;
|
||||
try {
|
||||
const [kindStr, pubkey, identifier] = repoAddress.split(":");
|
||||
return {
|
||||
kind: parseInt(kindStr),
|
||||
pubkey,
|
||||
identifier,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [repoAddress]);
|
||||
|
||||
// Fetch repository event
|
||||
const repoEvent = useNostrEvent(repoPointer || undefined);
|
||||
|
||||
// Get repository display name
|
||||
const repoName = repoEvent
|
||||
? getRepositoryName(repoEvent) ||
|
||||
getRepositoryIdentifier(repoEvent) ||
|
||||
"Repository"
|
||||
: repoPointer?.identifier || "Unknown Repository";
|
||||
|
||||
// Format created date
|
||||
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
|
||||
const handleRepoClick = () => {
|
||||
if (!repoPointer || !repoEvent) return;
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
|
||||
{/* PR Header */}
|
||||
<header className="flex flex-col gap-4 pb-4 border-b border-border">
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold">
|
||||
{subject || "Untitled Pull Request"}
|
||||
</h1>
|
||||
|
||||
{/* Repository Link */}
|
||||
{repoAddress && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Repository:</span>
|
||||
<button
|
||||
onClick={repoEvent ? handleRepoClick : undefined}
|
||||
disabled={!repoEvent}
|
||||
className={`flex items-center gap-2 font-mono ${
|
||||
repoEvent
|
||||
? "text-muted-foreground underline decoration-dotted cursor-crosshair hover:text-primary"
|
||||
: "text-muted-foreground cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<FolderGit2 className="size-4" />
|
||||
{repoName}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>By</span>
|
||||
<UserName pubkey={event.pubkey} className="font-semibold" />
|
||||
</div>
|
||||
<span>•</span>
|
||||
<time>{createdDate}</time>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag className="size-3 text-muted-foreground" />
|
||||
{labels.map((label, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-3 py-1 border border-muted border-dotted text-muted-foreground text-xs"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Branch and Commit Info */}
|
||||
{(branchName || commitId || mergeBase) && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<GitBranch className="size-5" />
|
||||
Branch Information
|
||||
</h2>
|
||||
|
||||
{/* Branch Name */}
|
||||
{branchName && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/30">
|
||||
<span className="text-sm text-muted-foreground">Branch:</span>
|
||||
<code className="flex-1 text-sm font-mono truncate line-clamp-1">
|
||||
{branchName}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(branchName)}
|
||||
className="flex-shrink-0 p-1 hover:bg-muted"
|
||||
aria-label="Copy branch name"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commit ID */}
|
||||
{commitId && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/30">
|
||||
<span className="text-sm text-muted-foreground">Commit:</span>
|
||||
<code className="flex-1 text-sm font-mono truncate line-clamp-1">
|
||||
{commitId}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(commitId)}
|
||||
className="flex-shrink-0 p-1 hover:bg-muted"
|
||||
aria-label="Copy commit ID"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Merge Base */}
|
||||
{mergeBase && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/30">
|
||||
<span className="text-sm text-muted-foreground">Merge Base:</span>
|
||||
<code className="flex-1 text-sm font-mono truncate line-clamp-1">
|
||||
{mergeBase}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(mergeBase)}
|
||||
className="flex-shrink-0 p-1 hover:bg-muted"
|
||||
aria-label="Copy merge base"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clone URLs */}
|
||||
{cloneUrls.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
Clone URLs
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{cloneUrls.map((url, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="flex items-center gap-2 p-2 bg-muted/30 font-mono"
|
||||
>
|
||||
<code className="flex-1 text-sm break-all line-clamp-1">
|
||||
{url}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(url)}
|
||||
className="flex-shrink-0 p-1 hover:bg-muted"
|
||||
aria-label="Copy clone URL"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* PR Description - Markdown */}
|
||||
{event.content ? (
|
||||
<>
|
||||
<article className="prose prose-invert prose-sm max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkNostrMentions]}
|
||||
skipHtml
|
||||
urlTransform={(url) => {
|
||||
if (url.startsWith("nostr:")) return url;
|
||||
return defaultUrlTransform(url);
|
||||
}}
|
||||
components={{
|
||||
img: ({ src, alt }) =>
|
||||
src ? (
|
||||
<MediaEmbed
|
||||
url={src}
|
||||
alt={alt}
|
||||
preset="preview"
|
||||
enableZoom
|
||||
className="my-4"
|
||||
/>
|
||||
) : null,
|
||||
a: ({ href, children, ...props }) => {
|
||||
if (!href) return null;
|
||||
|
||||
if (href.startsWith("nostr:")) {
|
||||
return <NostrMention href={href} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
h1: ({ ...props }) => (
|
||||
<h1 className="text-2xl font-bold mt-8 mb-4" {...props} />
|
||||
),
|
||||
h2: ({ ...props }) => (
|
||||
<h2 className="text-xl font-bold mt-6 mb-3" {...props} />
|
||||
),
|
||||
h3: ({ ...props }) => (
|
||||
<h3 className="text-lg font-bold mt-4 mb-2" {...props} />
|
||||
),
|
||||
p: ({ ...props }) => (
|
||||
<p className="text-sm leading-relaxed mb-4" {...props} />
|
||||
),
|
||||
code: ({ ...props }: any) => (
|
||||
<code
|
||||
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
blockquote: ({ ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ ...props }) => (
|
||||
<ul
|
||||
className="text-sm list-disc list-inside my-4 space-y-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol
|
||||
className="text-sm list-decimal list-inside my-4 space-y-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
hr: () => <hr className="my-4" />,
|
||||
}}
|
||||
>
|
||||
{event.content.replace(/\\n/g, '\n')}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
(No description provided)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/nostr/kinds/PullRequestRenderer.tsx
Normal file
126
src/components/nostr/kinds/PullRequestRenderer.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import {
|
||||
getPullRequestSubject,
|
||||
getPullRequestLabels,
|
||||
getPullRequestBranchName,
|
||||
getPullRequestRepositoryAddress,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1618 - Pull Request
|
||||
* Displays as a compact PR card in feed view
|
||||
*/
|
||||
export function PullRequestRenderer({ event }: BaseEventProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const subject = getPullRequestSubject(event);
|
||||
const labels = getPullRequestLabels(event);
|
||||
const branchName = getPullRequestBranchName(event);
|
||||
const repoAddress = getPullRequestRepositoryAddress(event);
|
||||
|
||||
// Parse repository address to get the pointer
|
||||
const repoPointer = repoAddress
|
||||
? (() => {
|
||||
try {
|
||||
// Address format: "kind:pubkey:identifier"
|
||||
const [kindStr, pubkey, identifier] = repoAddress.split(":");
|
||||
return {
|
||||
kind: parseInt(kindStr),
|
||||
pubkey,
|
||||
identifier,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
|
||||
// Fetch the repository event to get its name
|
||||
const repoEvent = useNostrEvent(
|
||||
repoPointer
|
||||
? {
|
||||
kind: repoPointer.kind,
|
||||
pubkey: repoPointer.pubkey,
|
||||
identifier: repoPointer.identifier,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Get repository display name
|
||||
const repoName = repoEvent
|
||||
? getRepositoryName(repoEvent) ||
|
||||
getRepositoryIdentifier(repoEvent) ||
|
||||
"Repository"
|
||||
: repoAddress?.split(":")[2] || "Unknown Repository";
|
||||
|
||||
const handleRepoClick = () => {
|
||||
if (!repoPointer) return;
|
||||
addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* PR Title */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={subject || "Untitled Pull Request"}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{subject || "Untitled Pull Request"}
|
||||
</ClickableEventTitle>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>in</span>
|
||||
{/* Repository */}
|
||||
{repoAddress && repoPointer && (
|
||||
<div
|
||||
onClick={handleRepoClick}
|
||||
className="flex items-center gap-1 text-muted-foreground cursor-crosshair underline decoration-dotted hover:text-primary"
|
||||
>
|
||||
<FolderGit2 className="size-3" />
|
||||
<span>{repoName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch Name */}
|
||||
{branchName && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<code className="text-muted-foreground font-mono text-xs">
|
||||
{branchName}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="flex items-center gap-1 overflow-x-scroll">
|
||||
{labels.map((label, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 border border-muted border-dotted text-xs text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
* Detail renderer for Kind 30617 - Repository
|
||||
* Displays full repository metadata with all URLs and maintainers
|
||||
*/
|
||||
export function Kind30617DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
export function RepositoryDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const name = useMemo(() => getRepositoryName(event), [event]);
|
||||
const description = useMemo(() => getRepositoryDescription(event), [event]);
|
||||
const identifier = useMemo(() => getRepositoryIdentifier(event), [event]);
|
||||
@@ -36,13 +36,6 @@ export function Kind30617DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
{/* Name */}
|
||||
<h1 className="text-3xl font-bold">{displayName}</h1>
|
||||
|
||||
{/* Identifier */}
|
||||
{identifier && (
|
||||
<code className="text-sm text-muted-foreground font-mono bg-muted px-2 py-1 w-fit">
|
||||
<UserName pubkey={event.pubkey} />/{identifier}
|
||||
</code>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { GitBranch, Globe } from "lucide-react";
|
||||
import {
|
||||
getRepositoryName,
|
||||
@@ -11,7 +15,7 @@ import { getReplaceableIdentifier } from "applesauce-core/helpers";
|
||||
* Renderer for Kind 30617 - Repository
|
||||
* Displays as a compact repository card in feed view
|
||||
*/
|
||||
export function Kind30617Renderer({ event }: BaseEventProps) {
|
||||
export function RepositoryRenderer({ event }: BaseEventProps) {
|
||||
const name = getRepositoryName(event);
|
||||
const description = getRepositoryDescription(event);
|
||||
const identifier = getReplaceableIdentifier(event);
|
||||
@@ -29,9 +33,14 @@ export function Kind30617Renderer({ event }: BaseEventProps) {
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={`Repository: ${displayName}`}
|
||||
className="text-lg font-semibold text-foreground"
|
||||
as="span"
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</ClickableEventTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,14 @@ import { Kind20Renderer } from "./Kind20Renderer";
|
||||
import { Kind21Renderer } from "./Kind21Renderer";
|
||||
import { Kind22Renderer } from "./Kind22Renderer";
|
||||
import { Kind1063Renderer } from "./Kind1063Renderer";
|
||||
import { Kind1621Renderer } from "./Kind1621Renderer";
|
||||
import { IssueRenderer } from "./IssueRenderer";
|
||||
import { PatchRenderer } from "./PatchRenderer";
|
||||
import { PullRequestRenderer } from "./PullRequestRenderer";
|
||||
import { Kind9735Renderer } from "./Kind9735Renderer";
|
||||
import { Kind9802Renderer } from "./Kind9802Renderer";
|
||||
import { Kind10002Renderer } from "./Kind10002Renderer";
|
||||
import { Kind30023Renderer } from "./Kind30023Renderer";
|
||||
import { Kind30617Renderer } from "./Kind30617Renderer";
|
||||
import { RepositoryRenderer } from "./RepositoryRenderer";
|
||||
import { Kind39701Renderer } from "./Kind39701Renderer";
|
||||
import { GenericRelayListRenderer } from "./GenericRelayListRenderer";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
@@ -36,7 +38,9 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
22: Kind22Renderer, // Short Video (NIP-71)
|
||||
1063: Kind1063Renderer, // File Metadata (NIP-94)
|
||||
1111: Kind1Renderer, // Post
|
||||
1621: Kind1621Renderer, // Issue (NIP-34)
|
||||
1617: PatchRenderer, // Patch (NIP-34)
|
||||
1618: PullRequestRenderer, // Pull Request (NIP-34)
|
||||
1621: IssueRenderer, // Issue (NIP-34)
|
||||
9735: Kind9735Renderer, // Zap Receipt
|
||||
9802: Kind9802Renderer, // Highlight
|
||||
10002: Kind10002Renderer, // Relay List Metadata (NIP-65)
|
||||
@@ -46,7 +50,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
10050: GenericRelayListRenderer, // DM Relay List (NIP-51)
|
||||
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
|
||||
30023: Kind30023Renderer, // Long-form Article
|
||||
30617: Kind30617Renderer, // Repository (NIP-34)
|
||||
30617: RepositoryRenderer, // Repository (NIP-34)
|
||||
39701: Kind39701Renderer, // Web Bookmarks (NIP-B0)
|
||||
};
|
||||
|
||||
|
||||
@@ -6,18 +6,24 @@ import {
|
||||
BarChart3,
|
||||
Bookmark,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Cloud,
|
||||
Coins,
|
||||
Compass,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileCode,
|
||||
FileDiff,
|
||||
FileEdit,
|
||||
FileText,
|
||||
Filter,
|
||||
Flag,
|
||||
FolderGit2,
|
||||
Gavel,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
Grid3x3,
|
||||
Hash,
|
||||
Heart,
|
||||
@@ -55,6 +61,7 @@ import {
|
||||
UserX,
|
||||
Video,
|
||||
Wallet,
|
||||
XCircle,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
@@ -335,7 +342,7 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
name: "Merge Request",
|
||||
description: "Merge Requests",
|
||||
nip: "54",
|
||||
icon: GitBranch,
|
||||
icon: GitMerge,
|
||||
},
|
||||
|
||||
// Marketplace
|
||||
@@ -457,14 +464,14 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
name: "Patch",
|
||||
description: "Git Patches",
|
||||
nip: "34",
|
||||
icon: GitBranch,
|
||||
icon: FileDiff,
|
||||
},
|
||||
1618: {
|
||||
kind: 1618,
|
||||
name: "Pull Request",
|
||||
description: "Git Pull Requests",
|
||||
nip: "34",
|
||||
icon: GitBranch,
|
||||
icon: GitPullRequest,
|
||||
},
|
||||
1619: {
|
||||
kind: 1619,
|
||||
@@ -492,28 +499,28 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
name: "Open Status",
|
||||
description: "Open",
|
||||
nip: "34",
|
||||
icon: Activity,
|
||||
icon: CircleDot,
|
||||
},
|
||||
1631: {
|
||||
kind: 1631,
|
||||
name: "Applied/Merged",
|
||||
description: "Applied / Merged for Patches; Resolved for Issues",
|
||||
nip: "34",
|
||||
icon: Activity,
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
1632: {
|
||||
kind: 1632,
|
||||
name: "Closed Status",
|
||||
description: "Closed",
|
||||
nip: "34",
|
||||
icon: Activity,
|
||||
icon: XCircle,
|
||||
},
|
||||
1633: {
|
||||
kind: 1633,
|
||||
name: "Draft Status",
|
||||
description: "Draft",
|
||||
nip: "34",
|
||||
icon: Activity,
|
||||
icon: FileEdit,
|
||||
},
|
||||
|
||||
// Problem tracking - External spec (nostrocket), commented out
|
||||
@@ -1219,14 +1226,14 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
name: "Repository",
|
||||
description: "Repository announcements",
|
||||
nip: "34",
|
||||
icon: GitBranch,
|
||||
icon: FolderGit2,
|
||||
},
|
||||
30618: {
|
||||
kind: 30618,
|
||||
name: "Repo State",
|
||||
description: "Repository state announcements",
|
||||
nip: "34",
|
||||
icon: GitBranch,
|
||||
icon: FolderGit2,
|
||||
},
|
||||
30818: {
|
||||
kind: 30818,
|
||||
|
||||
85
src/lib/event-title.ts
Normal file
85
src/lib/event-title.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { kinds } from "nostr-tools";
|
||||
import { getArticleTitle } from "applesauce-core/helpers/article";
|
||||
import {
|
||||
getRepositoryName,
|
||||
getIssueTitle,
|
||||
getPatchSubject,
|
||||
getPullRequestSubject,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { getKindInfo } from "@/constants/kinds";
|
||||
|
||||
/**
|
||||
* Get a human-readable display title for any event
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Kind-specific helper functions (most accurate)
|
||||
* 2. Generic 'subject' or 'title' tags
|
||||
* 3. Event kind name as fallback
|
||||
*
|
||||
* @param event - The Nostr event
|
||||
* @returns Human-readable title string
|
||||
*/
|
||||
export function getEventDisplayTitle(
|
||||
event: NostrEvent,
|
||||
showKind = true,
|
||||
): string {
|
||||
// Try kind-specific helpers first (most accurate)
|
||||
let title: string | undefined;
|
||||
|
||||
switch (event.kind) {
|
||||
case kinds.LongFormArticle: // Long-form article
|
||||
title = getArticleTitle(event);
|
||||
break;
|
||||
case 30617: // Repository
|
||||
title = getRepositoryName(event);
|
||||
break;
|
||||
case 1621: // Issue
|
||||
title = getIssueTitle(event);
|
||||
break;
|
||||
case 1617: // Patch
|
||||
title = getPatchSubject(event);
|
||||
break;
|
||||
case 1618: // Pull request
|
||||
title = getPullRequestSubject(event);
|
||||
break;
|
||||
}
|
||||
|
||||
if (title) return title;
|
||||
|
||||
// Try generic tag extraction
|
||||
title =
|
||||
getTagValue(event, "subject") ||
|
||||
getTagValue(event, "title") ||
|
||||
getTagValue(event, "name");
|
||||
if (title) return title;
|
||||
|
||||
// Fall back to kind name
|
||||
const kindInfo = getKindInfo(event.kind);
|
||||
if (showKind && kindInfo) {
|
||||
return kindInfo.name;
|
||||
}
|
||||
|
||||
// Ultimate fallback
|
||||
if (showKind) {
|
||||
return `Kind ${event.kind}`;
|
||||
}
|
||||
|
||||
return event.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a window title for an event with optional prefix
|
||||
*
|
||||
* @param event - The Nostr event
|
||||
* @param prefix - Optional prefix for the title (e.g., "Repository:", "Article:")
|
||||
* @returns Formatted window title
|
||||
*/
|
||||
export function getEventWindowTitle(
|
||||
event: NostrEvent,
|
||||
prefix?: string,
|
||||
): string {
|
||||
const title = getEventDisplayTitle(event);
|
||||
return prefix ? `${prefix} ${title}` : title;
|
||||
}
|
||||
180
src/lib/nip34-helpers.test.ts
Normal file
180
src/lib/nip34-helpers.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
/**
|
||||
* Test helper to parse unified diff content
|
||||
* This is a copy of the parseUnifiedDiff function from PatchDetailRenderer
|
||||
* for testing purposes
|
||||
*/
|
||||
function parseUnifiedDiff(content: string): {
|
||||
hunks: string[];
|
||||
oldFile?: { fileName?: string };
|
||||
newFile?: { fileName?: string };
|
||||
} | null {
|
||||
const lines = content.split("\n");
|
||||
const hunks: string[] = [];
|
||||
let oldFileName: string | undefined;
|
||||
let newFileName: string | undefined;
|
||||
let currentHunk: string[] = [];
|
||||
let inHunk = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Extract file names from --- and +++ lines
|
||||
if (line.startsWith("---")) {
|
||||
const match = line.match(/^---\s+(?:a\/)?(.+?)(?:\s|$)/);
|
||||
if (match) oldFileName = match[1];
|
||||
} else if (line.startsWith("+++")) {
|
||||
const match = line.match(/^\+\+\+\s+(?:b\/)?(.+?)(?:\s|$)/);
|
||||
if (match) newFileName = match[1];
|
||||
}
|
||||
// Start of a new hunk
|
||||
else if (line.startsWith("@@")) {
|
||||
// Save previous hunk if exists
|
||||
if (currentHunk.length > 0) {
|
||||
hunks.push(currentHunk.join("\n"));
|
||||
}
|
||||
currentHunk = [line];
|
||||
inHunk = true;
|
||||
}
|
||||
// Content lines within a hunk (start with +, -, or space)
|
||||
else if (
|
||||
inHunk &&
|
||||
(line.startsWith("+") ||
|
||||
line.startsWith("-") ||
|
||||
line.startsWith(" ") ||
|
||||
line === "")
|
||||
) {
|
||||
currentHunk.push(line);
|
||||
}
|
||||
// End of current hunk
|
||||
else if (inHunk && !line.startsWith("@@")) {
|
||||
if (currentHunk.length > 0) {
|
||||
hunks.push(currentHunk.join("\n"));
|
||||
currentHunk = [];
|
||||
}
|
||||
inHunk = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last hunk
|
||||
if (currentHunk.length > 0) {
|
||||
hunks.push(currentHunk.join("\n"));
|
||||
}
|
||||
|
||||
if (hunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
hunks,
|
||||
oldFile: oldFileName ? { fileName: oldFileName } : undefined,
|
||||
newFile: newFileName ? { fileName: newFileName } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseUnifiedDiff", () => {
|
||||
it("should parse a simple unified diff", () => {
|
||||
const diff = `diff --git a/file.ts b/file.ts
|
||||
index abc123..def456 100644
|
||||
--- a/file.ts
|
||||
+++ b/file.ts
|
||||
@@ -1,5 +1,6 @@
|
||||
context line
|
||||
-deleted line
|
||||
+added line
|
||||
context line`;
|
||||
|
||||
const result = parseUnifiedDiff(diff);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hunks).toHaveLength(1);
|
||||
expect(result?.oldFile?.fileName).toBe("file.ts");
|
||||
expect(result?.newFile?.fileName).toBe("file.ts");
|
||||
expect(result?.hunks[0]).toContain("@@ -1,5 +1,6 @@");
|
||||
expect(result?.hunks[0]).toContain("-deleted line");
|
||||
expect(result?.hunks[0]).toContain("+added line");
|
||||
});
|
||||
|
||||
it("should parse multiple hunks", () => {
|
||||
const diff = `--- a/file.ts
|
||||
+++ b/file.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
line 1
|
||||
-old line 2
|
||||
+new line 2
|
||||
line 3
|
||||
@@ -10,3 +10,4 @@
|
||||
line 10
|
||||
+new line 11
|
||||
line 12`;
|
||||
|
||||
const result = parseUnifiedDiff(diff);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hunks).toHaveLength(2);
|
||||
expect(result?.hunks[0]).toContain("@@ -1,3 +1,3 @@");
|
||||
expect(result?.hunks[1]).toContain("@@ -10,3 +10,4 @@");
|
||||
});
|
||||
|
||||
it("should extract file names with a/ and b/ prefixes", () => {
|
||||
const diff = `--- a/src/components/Button.tsx
|
||||
+++ b/src/components/Button.tsx
|
||||
@@ -1,1 +1,1 @@
|
||||
-old
|
||||
+new`;
|
||||
|
||||
const result = parseUnifiedDiff(diff);
|
||||
expect(result?.oldFile?.fileName).toBe("src/components/Button.tsx");
|
||||
expect(result?.newFile?.fileName).toBe("src/components/Button.tsx");
|
||||
});
|
||||
|
||||
it("should extract file names without a/ and b/ prefixes", () => {
|
||||
const diff = `--- file.ts
|
||||
+++ file.ts
|
||||
@@ -1,1 +1,1 @@
|
||||
-old
|
||||
+new`;
|
||||
|
||||
const result = parseUnifiedDiff(diff);
|
||||
expect(result?.oldFile?.fileName).toBe("file.ts");
|
||||
expect(result?.newFile?.fileName).toBe("file.ts");
|
||||
});
|
||||
|
||||
it("should return null for content with no hunks", () => {
|
||||
const diff = `This is not a valid diff
|
||||
Just some random text`;
|
||||
|
||||
const result = parseUnifiedDiff(diff);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle empty lines within hunks", () => {
|
||||
const diff = `--- a/file.ts
|
||||
+++ b/file.ts
|
||||
@@ -1,5 +1,5 @@
|
||||
line 1
|
||||
|
||||
-old line 3
|
||||
+new line 3
|
||||
line 4`;
|
||||
|
||||
const result = parseUnifiedDiff(diff);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hunks).toHaveLength(1);
|
||||
expect(result?.hunks[0]).toContain("");
|
||||
});
|
||||
|
||||
it("should handle context lines (starting with space)", () => {
|
||||
const diff = `--- a/file.ts
|
||||
+++ b/file.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
context line 1
|
||||
-deleted
|
||||
+added
|
||||
context line 3`;
|
||||
|
||||
const result = parseUnifiedDiff(diff);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hunks[0]).toContain(" context line 1");
|
||||
expect(result?.hunks[0]).toContain(" context line 3");
|
||||
});
|
||||
});
|
||||
@@ -122,3 +122,160 @@ export function getIssueRepositoryAddress(
|
||||
export function getIssueRepositoryOwner(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "p");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Patch Event Helpers (Kind 1617)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the patch subject from content or subject tag
|
||||
* @param event Patch event (kind 1617)
|
||||
* @returns Patch subject/title or undefined
|
||||
*/
|
||||
export function getPatchSubject(event: NostrEvent): string | undefined {
|
||||
// Try subject tag first
|
||||
const subjectTag = getTagValue(event, "subject");
|
||||
if (subjectTag) return subjectTag;
|
||||
|
||||
// Try to extract from content (first line or "Subject:" header from git format-patch)
|
||||
const content = event.content.trim();
|
||||
const subjectMatch = content.match(/^Subject:\s*(.+?)$/m);
|
||||
if (subjectMatch) return subjectMatch[1].trim();
|
||||
|
||||
// Fallback to first line
|
||||
const firstLine = content.split("\n")[0];
|
||||
return firstLine?.length > 0 ? firstLine : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the commit ID from a patch event
|
||||
* @param event Patch event (kind 1617)
|
||||
* @returns Commit ID or undefined
|
||||
*/
|
||||
export function getPatchCommitId(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "commit");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent commit ID from a patch event
|
||||
* @param event Patch event (kind 1617)
|
||||
* @returns Parent commit ID or undefined
|
||||
*/
|
||||
export function getPatchParentCommit(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "parent-commit");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get committer info from a patch event
|
||||
* @param event Patch event (kind 1617)
|
||||
* @returns Committer object with name, email, timestamp, timezone or undefined
|
||||
*/
|
||||
export function getPatchCommitter(
|
||||
event: NostrEvent,
|
||||
):
|
||||
| { name: string; email: string; timestamp: string; timezone: string }
|
||||
| undefined {
|
||||
const committerTag = event.tags.find((t) => t[0] === "committer");
|
||||
if (!committerTag || committerTag.length < 5) return undefined;
|
||||
|
||||
const [, name, email, timestamp, timezone] = committerTag;
|
||||
return { name, email, timestamp, timezone };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repository address for a patch
|
||||
* @param event Patch event (kind 1617)
|
||||
* @returns Repository address pointer (a tag) or undefined
|
||||
*/
|
||||
export function getPatchRepositoryAddress(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "a");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if patch is root/first in series
|
||||
* @param event Patch event (kind 1617)
|
||||
* @returns True if this is a root patch
|
||||
*/
|
||||
export function isPatchRoot(event: NostrEvent): boolean {
|
||||
return event.tags.some((t) => t[0] === "t" && t[1] === "root");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if patch is first in a revision series
|
||||
* @param event Patch event (kind 1617)
|
||||
* @returns True if this is a root revision
|
||||
*/
|
||||
export function isPatchRootRevision(event: NostrEvent): boolean {
|
||||
return event.tags.some((t) => t[0] === "t" && t[1] === "root-revision");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pull Request Event Helpers (Kind 1618)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the PR subject/title
|
||||
* @param event PR event (kind 1618)
|
||||
* @returns PR subject or undefined
|
||||
*/
|
||||
export function getPullRequestSubject(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "subject");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PR labels
|
||||
* @param event PR event (kind 1618)
|
||||
* @returns Array of label strings
|
||||
*/
|
||||
export function getPullRequestLabels(event: NostrEvent): string[] {
|
||||
return event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current commit ID (tip of PR branch)
|
||||
* @param event PR event (kind 1618)
|
||||
* @returns Commit ID or undefined
|
||||
*/
|
||||
export function getPullRequestCommitId(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "c");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all clone URLs for a PR
|
||||
* @param event PR event (kind 1618)
|
||||
* @returns Array of clone URLs
|
||||
*/
|
||||
export function getPullRequestCloneUrls(event: NostrEvent): string[] {
|
||||
return event.tags.filter((t) => t[0] === "clone").map((t) => t[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the branch name for a PR
|
||||
* @param event PR event (kind 1618)
|
||||
* @returns Branch name or undefined
|
||||
*/
|
||||
export function getPullRequestBranchName(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "branch-name");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the merge base commit ID
|
||||
* @param event PR event (kind 1618)
|
||||
* @returns Merge base commit ID or undefined
|
||||
*/
|
||||
export function getPullRequestMergeBase(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "merge-base");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repository address for a PR
|
||||
* @param event PR event (kind 1618)
|
||||
* @returns Repository address pointer (a tag) or undefined
|
||||
*/
|
||||
export function getPullRequestRepositoryAddress(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "a");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user